Allow group admins to moderate new members

Closes #881

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-11-12 15:42:52 +01:00
parent ae24fa17d5
commit 6eba531c89
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
28 changed files with 795 additions and 212 deletions

View file

@ -148,6 +148,11 @@ export default class GroupActivityItem extends mixins(ActivityMixin) {
case Openness.INVITE_ONLY: case Openness.INVITE_ONLY:
details.push("The group can now only be joined with an invite."); details.push("The group can now only be joined with an invite.");
break; break;
case Openness.MODERATED:
details.push(
"The group can now be joined by anyone, but new members need to be approved by an administrator."
);
break;
case Openness.OPEN: case Openness.OPEN:
details.push("The group can now be joined by anyone."); details.push("The group can now be joined by anyone.");
break; break;

View file

@ -9,13 +9,7 @@
:inline="true" :inline="true"
slot="member" slot="member"
> >
<b> <b> {{ displayName(activity.object.actor) }}</b></popover-actor-card
{{
$t("@{username}", {
username: usernameWithDomain(activity.object.actor),
})
}}</b
></popover-actor-card
> >
<b slot="member" v-else>{{ <b slot="member" v-else>{{
subjectParams.member_actor_federated_username subjectParams.member_actor_federated_username
@ -25,13 +19,7 @@
:inline="true" :inline="true"
slot="profile" slot="profile"
> >
<b> <b> {{ displayName(activity.author) }}</b></popover-actor-card
{{
$t("@{username}", {
username: usernameWithDomain(activity.author),
})
}}</b
></popover-actor-card
></i18n ></i18n
> >
<small class="has-text-grey-dark activity-date">{{ <small class="has-text-grey-dark activity-date">{{
@ -41,7 +29,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { usernameWithDomain } from "@/types/actor"; import { displayName } from "@/types/actor";
import { ActivityMemberSubject, MemberRole } from "@/types/enums"; import { ActivityMemberSubject, MemberRole } from "@/types/enums";
import { Component } from "vue-property-decorator"; import { Component } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -62,7 +50,7 @@ export const MEMBER_ROLE_VALUE: Record<string, number> = {
}, },
}) })
export default class MemberActivityItem extends mixins(ActivityMixin) { export default class MemberActivityItem extends mixins(ActivityMixin) {
usernameWithDomain = usernameWithDomain; displayName = displayName;
RouteName = RouteName; RouteName = RouteName;
ActivityMemberSubject = ActivityMemberSubject; ActivityMemberSubject = ActivityMemberSubject;
@ -83,6 +71,14 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
return "You added the member {member}."; return "You added the member {member}.";
} }
return "{profile} added the member {member}."; return "{profile} added the member {member}.";
case ActivityMemberSubject.MEMBER_APPROVED:
if (this.isAuthorCurrentActor) {
return "You approved {member}'s membership.";
}
if (this.isObjectMemberCurrentActor) {
return "Your membership was approved by {profile}.";
}
return "{profile} approved {member}'s membership.";
case ActivityMemberSubject.MEMBER_JOINED: case ActivityMemberSubject.MEMBER_JOINED:
return "{member} joined the group."; return "{member} joined the group.";
case ActivityMemberSubject.MEMBER_UPDATED: case ActivityMemberSubject.MEMBER_UPDATED:
@ -94,6 +90,12 @@ export default class MemberActivityItem extends mixins(ActivityMixin) {
} }
return "{profile} updated the member {member}."; return "{profile} updated the member {member}.";
case ActivityMemberSubject.MEMBER_REMOVED: case ActivityMemberSubject.MEMBER_REMOVED:
if (this.subjectParams.member_role === MemberRole.NOT_APPROVED) {
if (this.isAuthorCurrentActor) {
return "You rejected {member}'s membership request.";
}
return "{profile} rejected {member}'s membership request.";
}
if (this.isAuthorCurrentActor) { if (this.isAuthorCurrentActor) {
return "You excluded member {member}."; return "You excluded member {member}.";
} }

View file

@ -1,5 +1,6 @@
<template> <template>
<div class="media"> <div class="card">
<div class="card-content media">
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<i18n <i18n
@ -19,7 +20,7 @@
<div class="media-content"> <div class="media-content">
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item mr-3">
<router-link <router-link
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.GROUP,
@ -28,8 +29,8 @@
}, },
}" }"
> >
<h3>{{ member.parent.name }}</h3> <h3 class="is-size-5">{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey"> <p class="is-size-7 has-text-grey-dark">
<span v-if="member.parent.domain"> <span v-if="member.parent.domain">
{{ {{
`@${member.parent.preferredUsername}@${member.parent.domain}` `@${member.parent.preferredUsername}@${member.parent.domain}`
@ -44,12 +45,18 @@
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
<b-button type="is-success" @click="$emit('accept', member.id)"> <b-button
type="is-success"
@click="$emit('accept', member.id)"
>
{{ $t("Accept") }} {{ $t("Accept") }}
</b-button> </b-button>
</div> </div>
<div class="level-item"> <div class="level-item">
<b-button type="is-danger" @click="$emit('reject', member.id)"> <b-button
type="is-danger"
@click="$emit('reject', member.id)"
>
{{ $t("Decline") }} {{ $t("Decline") }}
</b-button> </b-button>
</div> </div>
@ -59,6 +66,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -82,4 +90,7 @@ export default class InvitationCard extends Vue {
background: lighten($primary, 40%); background: lighten($primary, 40%);
padding: 10px; padding: 10px;
} }
h3 {
color: $violet-3;
}
</style> </style>

View file

@ -1,5 +1,5 @@
<template> <template>
<section v-if="invitations && invitations.length > 0"> <section class="card my-3" v-if="invitations && invitations.length > 0">
<InvitationCard <InvitationCard
v-for="member in invitations" v-for="member in invitations"
:key="member.id" :key="member.id"
@ -13,8 +13,9 @@
import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member"; import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import InvitationCard from "@/components/Group/InvitationCard.vue"; import InvitationCard from "@/components/Group/InvitationCard.vue";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor"; import { PERSON_STATUS_GROUP } from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model"; import { IMember } from "@/types/actor/member.model";
import { IGroup, IPerson, usernameWithDomain } from "@/types/actor";
@Component({ @Component({
components: { components: {
@ -26,18 +27,25 @@ export default class Invitations extends Vue {
async acceptInvitation(id: string): Promise<void> { async acceptInvitation(id: string): Promise<void> {
try { try {
const { data } = await this.$apollo.mutate<{ acceptInvitation: IMember }>( await this.$apollo.mutate<{ acceptInvitation: IMember }>({
{
mutation: ACCEPT_INVITATION, mutation: ACCEPT_INVITATION,
variables: { variables: {
id, id,
}, },
refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }], refetchQueries({ data }) {
} const profile = data?.acceptInvitation?.actor as IPerson;
); const group = data?.acceptInvitation?.parent as IGroup;
if (data) { if (profile && group) {
this.$emit("accept-invitation", data.acceptInvitation); return [
{
query: PERSON_STATUS_GROUP,
variables: { id: profile.id, group: usernameWithDomain(group) },
},
];
} }
return [];
},
});
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) { if (error.graphQLErrors && error.graphQLErrors.length > 0) {
@ -48,18 +56,25 @@ export default class Invitations extends Vue {
async rejectInvitation(id: string): Promise<void> { async rejectInvitation(id: string): Promise<void> {
try { try {
const { data } = await this.$apollo.mutate<{ rejectInvitation: IMember }>( await this.$apollo.mutate<{ rejectInvitation: IMember }>({
{
mutation: REJECT_INVITATION, mutation: REJECT_INVITATION,
variables: { variables: {
id, id,
}, },
refetchQueries: [{ query: LOGGED_USER_MEMBERSHIPS }], refetchQueries({ data }) {
} const profile = data?.rejectInvitation?.actor as IPerson;
); const group = data?.rejectInvitation?.parent as IGroup;
if (data) { if (profile && group) {
this.$emit("reject-invitation", data.rejectInvitation); return [
{
query: PERSON_STATUS_GROUP,
variables: { id: profile.id, group: usernameWithDomain(group) },
},
];
} }
return [];
},
});
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) { if (error.graphQLErrors && error.graphQLErrors.length > 0) {

View file

@ -37,9 +37,10 @@ export const ACCEPT_INVITATION = gql`
export const REJECT_INVITATION = gql` export const REJECT_INVITATION = gql`
mutation RejectInvitation($id: ID!) { mutation RejectInvitation($id: ID!) {
rejectInvitation(id: $id) { rejectInvitation(id: $id) {
id ...MemberFragment
} }
} }
${MEMBER_FRAGMENT}
`; `;
export const GROUP_MEMBERS = gql` export const GROUP_MEMBERS = gql`
@ -72,13 +73,22 @@ export const UPDATE_MEMBER = gql`
`; `;
export const REMOVE_MEMBER = gql` export const REMOVE_MEMBER = gql`
mutation RemoveMember($groupId: ID!, $memberId: ID!) { mutation RemoveMember($memberId: ID!, $exclude: Boolean) {
removeMember(groupId: $groupId, memberId: $memberId) { removeMember(memberId: $memberId, exclude: $exclude) {
id id
} }
} }
`; `;
export const APPROVE_MEMBER = gql`
mutation ApproveMember($memberId: ID!) {
approveMember(memberId: $memberId) {
...MemberFragment
}
}
${MEMBER_FRAGMENT}
`;
export const JOIN_GROUP = gql` export const JOIN_GROUP = gql`
mutation JoinGroup($groupId: ID!) { mutation JoinGroup($groupId: ID!) {
joinGroup(groupId: $groupId) { joinGroup(groupId: $groupId) {

View file

@ -881,6 +881,7 @@
"{member} was invited by {profile}.": "{member} was invited by {profile}.", "{member} was invited by {profile}.": "{member} was invited by {profile}.",
"You added the member {member}.": "You added the member {member}.", "You added the member {member}.": "You added the member {member}.",
"{profile} added the member {member}.": "{profile} added the member {member}.", "{profile} added the member {member}.": "{profile} added the member {member}.",
"{member} joined the group.": "{member} joined the group.",
"{member} rejected the invitation to join the group.": "{member} rejected the invitation to join the group.", "{member} rejected the invitation to join the group.": "{member} rejected the invitation to join the group.",
"{member} accepted the invitation to join the group.": "{member} accepted the invitation to join the group.", "{member} accepted the invitation to join the group.": "{member} accepted the invitation to join the group.",
"You excluded member {member}.": "You excluded member {member}.", "You excluded member {member}.": "You excluded member {member}.",
@ -1233,5 +1234,17 @@
"Any type": "Any type", "Any type": "Any type",
"In person": "In person", "In person": "In person",
"In the past": "In the past", "In the past": "In the past",
"Only registered users may fetch remote events from their URL.": "Only registered users may fetch remote events from their URL." "Only registered users may fetch remote events from their URL.": "Only registered users may fetch remote events from their URL.",
"Moderate new members": "Moderate new members",
"Anyone can request being a member, but an administrator needs to approve the membership.": "Anyone can request being a member, but an administrator needs to approve the membership.",
"Cancel membership request": "Cancel membership request",
"group's upcoming public events": "group's upcoming public events",
"access to the group's private content as well": "access to the group's private content as well",
"Following the group will allow you to be informed of the {group_upcoming_public_events}, whereas joining the group means you will {access_to_group_private_content_as_well}, including group discussions, group resources and members-only posts.": "Following the group will allow you to be informed of the {group_upcoming_public_events}, whereas joining the group means you will {access_to_group_private_content_as_well}, including group discussions, group resources and members-only posts.",
"The group can now be joined by anyone, but new members need to be approved by an administrator.": "The group can now be joined by anyone, but new members need to be approved by an administrator.",
"You approved {member}'s membership.": "You approved {member}'s membership.",
"Your membership was approved by {profile}.": "Your membership was approved by {profile}.",
"{profile} approved {member}'s membership.": "{profile} approved {member}'s membership.",
"You rejected {member}'s membership request.": "You rejected {member}'s membership request.",
"{profile} rejected {member}'s membership request.": "{profile} rejected {member}'s membership request."
} }

View file

@ -1077,7 +1077,7 @@
"You excluded member {member}.": "Vous avez exclu le ou la membre {member}.", "You excluded member {member}.": "Vous avez exclu le ou la membre {member}.",
"You have been disconnected": "Vous avez été déconnecté⋅e", "You have been disconnected": "Vous avez été déconnecté⋅e",
"You have been invited by {invitedBy} to the following group:": "Vous avez été invité par {invitedBy} à rejoindre le groupe suivant :", "You have been invited by {invitedBy} to the following group:": "Vous avez été invité par {invitedBy} à rejoindre le groupe suivant :",
"You have been removed from this group's members.": "Vous avez été exclu des membres de ce groupe.", "You have been removed from this group's members.": "Vous avez été exclu⋅e des membres de ce groupe.",
"You have cancelled your participation": "Vous avez annulé votre participation", "You have cancelled your participation": "Vous avez annulé votre participation",
"You have one event in {days} days.": "Vous n'avez pas d'événements dans {days} jours | Vous avez un événement dans {days} jours. | Vous avez {count} événements dans {days} jours", "You have one event in {days} days.": "Vous n'avez pas d'événements dans {days} jours | Vous avez un événement dans {days} jours. | Vous avez {count} événements dans {days} jours",
"You have one event today.": "Vous n'avez pas d'événement aujourd'hui | Vous avez un événement aujourd'hui. | Vous avez {count} événements aujourd'hui", "You have one event today.": "Vous n'avez pas d'événement aujourd'hui | Vous avez un événement aujourd'hui. | Vous avez {count} événements aujourd'hui",
@ -1231,6 +1231,7 @@
"{old_group_name} was renamed to {group}.": "{old_group_name} a été renommé en {group}.", "{old_group_name} was renamed to {group}.": "{old_group_name} a été renommé en {group}.",
"{profile} (by default)": "{profile} (par défault)", "{profile} (by default)": "{profile} (par défault)",
"{profile} added the member {member}.": "{profile} a ajouté le ou la membre {member}.", "{profile} added the member {member}.": "{profile} a ajouté le ou la membre {member}.",
"{member} joined the group.": "{member} a rejoint le groupe.",
"{profile} archived the discussion {discussion}.": "{profile} a archivé la discussion {discussion}.", "{profile} archived the discussion {discussion}.": "{profile} a archivé la discussion {discussion}.",
"{profile} created the discussion {discussion}.": "{profile} a créé la discussion {discussion}.", "{profile} created the discussion {discussion}.": "{profile} a créé la discussion {discussion}.",
"{profile} created the folder {resource}.": "{profile} a créé le dossier {resource}.", "{profile} created the folder {resource}.": "{profile} a créé le dossier {resource}.",
@ -1337,5 +1338,17 @@
"Any type": "N'importe quel type", "Any type": "N'importe quel type",
"In person": "En personne", "In person": "En personne",
"In the past": "Dans le passé", "In the past": "Dans le passé",
"Only registered users may fetch remote events from their URL.": "Seul⋅es les utilisateur⋅ices enregistré⋅es peuvent récupérer des événements depuis leur URL." "Only registered users may fetch remote events from their URL.": "Seul⋅es les utilisateur⋅ices enregistré⋅es peuvent récupérer des événements depuis leur URL.",
"Moderate new members": "Modérer les nouvelles et nouveaux membres",
"Anyone can request being a member, but an administrator needs to approve the membership.": "N'importe qui peut demander à être membre, mais un⋅e administrateur⋅ice devra approuver leur adhésion.",
"Cancel membership request": "Annuler la demande d'adhésion",
"group's upcoming public events": "prochains événements publics du groupe",
"access to the group's private content as well": "accédez également au contenu privé du groupe",
"Following the group will allow you to be informed of the {group_upcoming_public_events}, whereas joining the group means you will {access_to_group_private_content_as_well}, including group discussions, group resources and members-only posts.": "Suivre le groupe vous permettra d'être informé⋅e des {group_upcoming_public_events}, alors que rejoindre le groupe signfie que vous {access_to_group_private_content_as_well}, y compris les discussion de groupe, les resources du groupe et les billets réservés au groupe.",
"The group can now be joined by anyone, but new members need to be approved by an administrator.": "Le groupe peut maintenant être rejoint par n'importe qui, mais les nouvelles et nouveaux membres doivent être approuvées par un⋅e modérateur⋅ice.",
"You approved {member}'s membership.": "Vous avez approuvé la demande d'adhésion de {member}.",
"Your membership was approved by {profile}.": "Votre demande d'adhésion a été approuvée par {profile}.",
"{profile} approved {member}'s membership.": "{profile} a approuvé la demande d'adhésion de {member}.",
"You rejected {member}'s membership request.": "Vous avez rejeté la demande d'adhésion de {member}.",
"{profile} rejected {member}'s membership request.": "{profile} a rejeté la demande d'adhésion de {member}."
} }

View file

@ -99,6 +99,10 @@ export default class GroupMixin extends Vue {
]); ]);
} }
get isCurrentActorAPendingGroupMember(): boolean {
return this.hasCurrentActorThisRole([MemberRole.NOT_APPROVED]);
}
hasCurrentActorThisRole(givenRole: string | string[]): boolean { hasCurrentActorThisRole(givenRole: string | string[]): boolean {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole]; const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return ( return (

View file

@ -23,29 +23,6 @@
</ul> </ul>
</nav> </nav>
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<invitations
v-if="isCurrentActorAnInvitedGroupMember"
:invitations="[groupMember]"
@acceptInvitation="acceptInvitation"
@reject-invitation="rejectInvitation"
/>
<b-message v-if="isCurrentActorARejectedGroupMember" type="is-danger">
{{ $t("You have been removed from this group's members.") }}
</b-message>
<b-message
v-if="
isCurrentActorAGroupMember &&
isCurrentActorARecentMember &&
isCurrentActorOnADifferentDomainThanGroup
"
type="is-info"
>
{{
$t(
"Since you are a new member, private content can take a few minutes to appear."
)
}}
</b-message>
<header class="block-container presentation" v-if="group"> <header class="block-container presentation" v-if="group">
<div class="banner-container"> <div class="banner-container">
<lazy-image-wrapper :picture="group.banner" /> <lazy-image-wrapper :picture="group.banner" />
@ -137,7 +114,7 @@
<b-tooltip <b-tooltip
v-if=" v-if="
(!isCurrentActorAGroupMember || previewPublic) && (!isCurrentActorAGroupMember || previewPublic) &&
group.openness !== Openness.OPEN group.openness === Openness.INVITE_ONLY
" "
:label="$t('This group is invite-only')" :label="$t('This group is invite-only')"
position="is-bottom" position="is-bottom"
@ -148,7 +125,9 @@
> >
<b-button <b-button
v-else-if=" v-else-if="
(!isCurrentActorAGroupMember || previewPublic) && ((!isCurrentActorAGroupMember &&
!isCurrentActorAPendingGroupMember) ||
previewPublic) &&
currentActor.id currentActor.id
" "
@click="joinGroup" @click="joinGroup"
@ -157,6 +136,14 @@
:disabled="previewPublic" :disabled="previewPublic"
>{{ $t("Join group") }}</b-button >{{ $t("Join group") }}</b-button
> >
<b-button
outlined
v-else-if="isCurrentActorAPendingGroupMember"
@click="leaveGroup"
@keyup.enter="leaveGroup"
type="is-primary"
>{{ $t("Cancel membership request") }}</b-button
>
<b-button <b-button
tag="router-link" tag="router-link"
:to="{ :to="{
@ -310,6 +297,49 @@
</b-dropdown> </b-dropdown>
</div> </div>
</div> </div>
<invitations
v-if="isCurrentActorAnInvitedGroupMember"
:invitations="[groupMember]"
/>
<b-message v-if="isCurrentActorARejectedGroupMember" type="is-danger">
{{ $t("You have been removed from this group's members.") }}
</b-message>
<b-message
v-if="
isCurrentActorAGroupMember &&
isCurrentActorARecentMember &&
isCurrentActorOnADifferentDomainThanGroup
"
type="is-info"
>
{{
$t(
"Since you are a new member, private content can take a few minutes to appear."
)
}}
</b-message>
<b-message
v-if="
!isCurrentActorAGroupMember &&
!isCurrentActorAPendingGroupMember &&
!isCurrentActorPendingFollow &&
!isCurrentActorFollowing
"
type="is-info"
has-icon
class="m-3"
>
<i18n
path="Following the group will allow you to be informed of the {group_upcoming_public_events}, whereas joining the group means you will {access_to_group_private_content_as_well}, including group discussions, group resources and members-only posts."
>
<b slot="group_upcoming_public_events">{{
$t("group's upcoming public events")
}}</b>
<b slot="access_to_group_private_content_as_well">{{
$t("access to the group's private content as well")
}}</b>
</i18n>
</b-message>
</div> </div>
</header> </header>
</div> </div>
@ -893,31 +923,6 @@ export default class Group extends mixins(GroupMixin) {
}); });
} }
acceptInvitation(): void {
if (this.groupMember) {
const index = this.person.memberships.elements.findIndex(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
({ id }: IMember) => id === this.groupMember.id
);
const member = this.groupMember;
member.role = MemberRole.MEMBER;
this.person.memberships.elements.splice(index, 1, member);
this.$apollo.queries.group.refetch();
}
}
rejectInvitation({ id: memberId }: { id: string }): void {
const index = this.person.memberships.elements.findIndex(
(membership) =>
membership.role === MemberRole.INVITED && membership.id === memberId
);
if (index > -1) {
this.person.memberships.elements.splice(index, 1);
this.person.memberships.total -= 1;
}
}
async reportGroup(content: string, forward: boolean): Promise<void> { async reportGroup(content: string, forward: boolean): Promise<void> {
this.isReportModalActive = false; this.isReportModalActive = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment

View file

@ -195,6 +195,20 @@
</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">
<div class="buttons" v-if="props.row.actor.id !== currentActor.id"> <div class="buttons" v-if="props.row.actor.id !== currentActor.id">
<b-button
type="is-success"
v-if="props.row.role === MemberRole.NOT_APPROVED"
@click="approveMember(props.row)"
icon-left="check"
>{{ $t("Approve member") }}</b-button
>
<b-button
type="is-danger"
v-if="props.row.role === MemberRole.NOT_APPROVED"
@click="rejectMember(props.row)"
icon-left="exit-to-app"
>{{ $t("Reject member") }}</b-button
>
<b-button <b-button
v-if=" v-if="
[MemberRole.MEMBER, MemberRole.MODERATOR].includes( [MemberRole.MEMBER, MemberRole.MODERATOR].includes(
@ -217,7 +231,7 @@
> >
<b-button <b-button
v-if="props.row.role === MemberRole.MEMBER" v-if="props.row.role === MemberRole.MEMBER"
@click="removeMember(props.row.id)" @click="removeMember(props.row)"
type="is-danger" type="is-danger"
icon-left="exit-to-app" icon-left="exit-to-app"
>{{ $t("Remove") }}</b-button >{{ $t("Remove") }}</b-button
@ -250,8 +264,9 @@ import {
GROUP_MEMBERS, GROUP_MEMBERS,
REMOVE_MEMBER, REMOVE_MEMBER,
UPDATE_MEMBER, UPDATE_MEMBER,
APPROVE_MEMBER,
} from "../../graphql/member"; } from "../../graphql/member";
import { usernameWithDomain } from "../../types/actor"; import { usernameWithDomain, displayName } from "../../types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue"; import EmptyContent from "@/components/Utils/EmptyContent.vue";
@Component({ @Component({
@ -332,7 +347,7 @@ export default class GroupMembers extends mixins(GroupMixin) {
this.$notifier.success( this.$notifier.success(
this.$t("{username} was invited to {group}", { this.$t("{username} was invited to {group}", {
username: this.newMemberUsername, username: this.newMemberUsername,
group: this.group.name || usernameWithDomain(this.group), group: displayName(this.group),
}) as string }) as string
); );
this.newMemberUsername = ""; this.newMemberUsername = "";
@ -375,7 +390,7 @@ export default class GroupMembers extends mixins(GroupMixin) {
}); });
} }
async removeMember(memberId: string): Promise<void> { async removeMember(oldMember: IMember): Promise<void> {
const { roles, MEMBERS_PER_PAGE, group, page } = this; const { roles, MEMBERS_PER_PAGE, group, page } = this;
const variables = { const variables = {
name: usernameWithDomain(group), name: usernameWithDomain(group),
@ -388,7 +403,7 @@ export default class GroupMembers extends mixins(GroupMixin) {
mutation: REMOVE_MEMBER, mutation: REMOVE_MEMBER,
variables: { variables: {
groupId: this.group.id, groupId: this.group.id,
memberId, memberId: oldMember.id,
}, },
refetchQueries: [ refetchQueries: [
{ {
@ -397,12 +412,18 @@ export default class GroupMembers extends mixins(GroupMixin) {
}, },
], ],
}); });
this.$notifier.success( let message = this.$t("The member was removed from the group {group}", {
this.$t("The member was removed from the group {group}", { group: displayName(this.group),
username: this.newMemberUsername, }) as string;
group: this.group.name || usernameWithDomain(this.group), if (oldMember.role === MemberRole.NOT_APPROVED) {
}) as string message = this.$t(
); "The membership request from {profile} was rejected",
{
group: displayName(oldMember.actor),
}
) as string;
}
this.$notifier.success(message);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) { if (error.graphQLErrors && error.graphQLErrors.length > 0) {
@ -414,29 +435,49 @@ export default class GroupMembers extends mixins(GroupMixin) {
promoteMember(member: IMember): void { promoteMember(member: IMember): void {
if (!member.id) return; if (!member.id) return;
if (member.role === MemberRole.MODERATOR) { if (member.role === MemberRole.MODERATOR) {
this.updateMember(member.id, MemberRole.ADMINISTRATOR); this.updateMember(member, MemberRole.ADMINISTRATOR);
} }
if (member.role === MemberRole.MEMBER) { if (member.role === MemberRole.MEMBER) {
this.updateMember(member.id, MemberRole.MODERATOR); this.updateMember(member, MemberRole.MODERATOR);
} }
} }
demoteMember(member: IMember): void { demoteMember(member: IMember): void {
if (!member.id) return; if (!member.id) return;
if (member.role === MemberRole.MODERATOR) { if (member.role === MemberRole.MODERATOR) {
this.updateMember(member.id, MemberRole.MEMBER); this.updateMember(member, MemberRole.MEMBER);
} }
if (member.role === MemberRole.ADMINISTRATOR) { if (member.role === MemberRole.ADMINISTRATOR) {
this.updateMember(member.id, MemberRole.MODERATOR); this.updateMember(member, MemberRole.MODERATOR);
} }
} }
async updateMember(memberId: string, role: MemberRole): Promise<void> { async approveMember(member: IMember): Promise<void> {
try {
await this.$apollo.mutate<{ approveMember: IMember }>({
mutation: APPROVE_MEMBER,
variables: { memberId: member.id },
});
this.$notifier.success(this.$t("The member was approved") as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
rejectMember(member: IMember): void {
if (!member.id) return;
this.removeMember(member);
}
async updateMember(oldMember: IMember, role: MemberRole): Promise<void> {
try { try {
await this.$apollo.mutate<{ updateMember: IMember }>({ await this.$apollo.mutate<{ updateMember: IMember }>({
mutation: UPDATE_MEMBER, mutation: UPDATE_MEMBER,
variables: { variables: {
memberId, memberId: oldMember.id,
role, role,
}, },
refetchQueries: [ refetchQueries: [
@ -455,9 +496,15 @@ export default class GroupMembers extends mixins(GroupMixin) {
successMessage = "The member role was updated to administrator"; successMessage = "The member role was updated to administrator";
break; break;
case MemberRole.MEMBER: case MemberRole.MEMBER:
default: if (oldMember.role === MemberRole.NOT_APPROVED) {
successMessage = "The member was approved";
} else {
successMessage = "The member role was updated to simple member"; successMessage = "The member role was updated to simple member";
} }
break;
default:
successMessage = "The member role was updated";
}
this.$notifier.success(this.$t(successMessage) as string); this.$notifier.success(this.$t(successMessage) as string);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);

View file

@ -128,6 +128,19 @@
}}</small> }}</small>
</b-radio> </b-radio>
</div> </div>
<div class="field">
<b-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.MODERATED"
>{{ $t("Moderate new members") }}<br />
<small>{{
$t(
"Anyone can request being a member, but an administrator needs to approve the membership."
)
}}</small>
</b-radio>
</div>
<div class="field"> <div class="field">
<b-radio <b-radio
v-model="editableGroup.openness" v-model="editableGroup.openness"

View file

@ -10,6 +10,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
alias Mobilizon.Federation.ActivityStream alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Convertible alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Service.Notifications.Scheduler alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Web.Email.Member, as: EmailMember
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
require Logger require Logger
@ -21,7 +22,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
maybe_relay_if_group_activity: 1 maybe_relay_if_group_activity: 1
] ]
@type acceptable_types :: :join | :follow | :invite @type acceptable_types :: :join | :follow | :invite | :member
@type acceptable_entities :: @type acceptable_entities ::
accept_join_entities | accept_follow_entities | accept_invite_entities accept_join_entities | accept_follow_entities | accept_invite_entities
@ -35,6 +36,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
:join -> accept_join(entity, additional) :join -> accept_join(entity, additional)
:follow -> accept_follow(entity, additional) :follow -> accept_follow(entity, additional)
:invite -> accept_invite(entity, additional) :invite -> accept_invite(entity, additional)
:member -> accept_member(entity, additional)
end end
with {:ok, entity, update_data} <- accept_res do with {:ok, entity, update_data} <- accept_res do
@ -158,12 +160,47 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Accept do
end end
end end
@spec maybe_refresh_group(Member.t()) :: :ok | nil @spec accept_member(Member.t(), map()) ::
{:ok, Member.t(), Activity.t()} | {:error, Ecto.Changeset.t()}
defp accept_member(
%Member{actor_id: actor_id, actor: actor, parent: %Actor{} = group} = member,
%{moderator: %Actor{url: actor_url} = moderator}
) do
with %Actor{} <- Actors.get_actor!(actor_id),
{:ok, %Member{id: member_id} = member} <-
Actors.update_member(member, %{role: :member}) do
Mobilizon.Service.Activity.Member.insert_activity(member,
subject: "member_approved",
moderator: moderator
)
Absinthe.Subscription.publish(Endpoint, actor,
group_membership_changed: [Actor.preferred_username_and_domain(group), actor_id]
)
EmailMember.send_notification_to_approved_member(member)
Cachex.del(:activity_pub, "member_#{member_id}")
maybe_refresh_group(member)
accept_data = %{
"type" => "Accept",
"attributedTo" => member.parent.url,
"to" => [member.parent.members_url],
"cc" => [member.parent.url],
"actor" => actor_url,
"object" => Convertible.model_to_as(member),
"id" => "#{Endpoint.url()}/accept/member/#{member_id}"
}
{:ok, member, accept_data}
end
end
@spec maybe_refresh_group(Member.t()) :: {:ok, Actor.t()} | {:error, atom()} | {:error}
defp maybe_refresh_group(%Member{ defp maybe_refresh_group(%Member{
parent: %Actor{domain: parent_domain, url: parent_url}, parent: %Actor{} = group
actor: %Actor{} = actor }),
}) do do: Refresher.refresh_profile(group)
unless is_nil(parent_domain),
do: Refresher.fetch_group(parent_url, actor)
end
end end

View file

@ -69,14 +69,20 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Leave do
end end
def leave( def leave(
%Actor{type: :Group, id: group_id, url: group_url, members_url: group_members_url}, %Actor{
%Actor{id: actor_id, url: actor_url}, type: :Group,
domain: group_domain,
id: group_id,
url: group_url,
members_url: group_members_url
},
%Actor{id: actor_id, url: actor_url, domain: actor_domain},
local, local,
additional additional
) do ) do
case Actors.get_member(actor_id, group_id) do case Actors.get_member(actor_id, group_id) do
{:ok, %Member{id: member_id} = member} -> {:ok, %Member{id: member_id} = member} ->
if Map.get(additional, :force_member_removal, false) || if Map.get(additional, :force_member_removal, false) || group_domain != actor_domain ||
!Actors.is_only_administrator?(member_id, group_id) do !Actors.is_only_administrator?(member_id, group_id) do
with {:ok, %Member{} = member} <- Actors.delete_member(member) do with {:ok, %Member{} = member} <- Actors.delete_member(member) do
Mobilizon.Service.Activity.Member.insert_activity(member, subject: "member_quit") Mobilizon.Service.Activity.Member.insert_activity(member, subject: "member_quit")

View file

@ -28,6 +28,7 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Reject do
:join -> reject_join(entity, additional) :join -> reject_join(entity, additional)
:follow -> reject_follow(entity, additional) :follow -> reject_follow(entity, additional)
:invite -> reject_invite(entity, additional) :invite -> reject_invite(entity, additional)
:member -> reject_member(entity, additional)
end end
{:ok, activity} = create_activity(update_data, local) {:ok, activity} = create_activity(update_data, local)
@ -118,4 +119,28 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Reject do
{:ok, member, accept_data} {:ok, member, accept_data}
end end
end end
@spec reject_member(Member.t(), map()) :: {:ok, Member.t(), Activity.t()} | any
defp reject_member(
%Member{actor_id: actor_id} = member,
%{moderator: %Actor{url: actor_url}}
) do
with %Actor{} <- Actors.get_actor(actor_id),
{:ok, %Member{url: member_url, id: member_id} = member} <-
Actors.delete_member(member),
Mobilizon.Service.Activity.Member.insert_activity(member,
subject: "member_rejected"
),
accept_data <- %{
"type" => "Reject",
"actor" => actor_url,
"attributedTo" => member.parent.url,
"to" => [member.parent.members_url],
"cc" => [member.parent.url],
"object" => member_url,
"id" => "#{Endpoint.url()}/reject/member/#{member_id}"
} do
{:ok, member, accept_data}
end
end
end end

View file

@ -21,23 +21,25 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Remove do
@spec remove(Member.t(), Actor.t(), Actor.t(), boolean, map) :: @spec remove(Member.t(), Actor.t(), Actor.t(), boolean, map) ::
{:ok, Activity.t(), Member.t()} | {:error, :member_not_found | Ecto.Changeset.t()} {:ok, Activity.t(), Member.t()} | {:error, :member_not_found | Ecto.Changeset.t()}
def remove( def remove(
%Member{} = member, %Member{id: member_id},
%Actor{type: :Group, url: group_url, members_url: group_members_url}, %Actor{type: :Group, url: group_url, members_url: group_members_url},
%Actor{url: moderator_url} = moderator, %Actor{url: moderator_url} = moderator,
local, local,
_additional \\ %{} _additional \\ %{}
) do ) do
with {:ok, %Member{id: member_id}} <- Actors.update_member(member, %{role: :rejected}), with %Member{actor: %Actor{url: actor_url}} = member <- Actors.get_member(member_id),
%Member{} = member <- Actors.get_member(member_id) do {:ok, %Member{}} <- Actors.delete_member(member) do
Mobilizon.Service.Activity.Member.insert_activity(member, Mobilizon.Service.Activity.Member.insert_activity(member,
moderator: moderator, moderator: moderator,
subject: "member_removed" subject: "member_removed"
) )
Cachex.del(:activity_pub, "member_#{member_id}")
EmailMember.send_notification_to_removed_member(member) EmailMember.send_notification_to_removed_member(member)
remove_data = %{ remove_data = %{
"to" => [group_members_url], "to" => [actor_url, group_members_url],
"type" => "Remove", "type" => "Remove",
"actor" => moderator_url, "actor" => moderator_url,
"object" => member.url, "object" => member.url,

View file

@ -740,14 +740,13 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
) do ) do
Logger.info("Handle incoming to remove a member from a group") Logger.info("Handle incoming to remove a member from a group")
with {:ok, %Actor{id: moderator_id} = moderator} <- with {:ok, %Actor{} = moderator} <-
data |> Utils.get_actor() |> ActivityPubActor.get_or_fetch_actor_by_url(), data |> Utils.get_actor() |> ActivityPubActor.get_or_fetch_actor_by_url(),
{:ok, person_id} <- get_remove_object(object), {:ok, person_id} <- get_remove_object(object),
{:ok, %Actor{type: :Group, id: group_id} = group} <- {:ok, %Actor{type: :Group, id: group_id} = group} <-
origin |> Utils.get_url() |> ActivityPubActor.get_or_fetch_actor_by_url(), origin |> Utils.get_url() |> ActivityPubActor.get_or_fetch_actor_by_url(),
{:is_admin, {:ok, %Member{role: role}}} {:is_admin, true} <-
when role in [:moderator, :administrator, :creator] <- {:is_admin, can_remove_actor_from_group?(moderator, group)},
{:is_admin, Actors.get_member(moderator_id, group_id)},
{:is_member, {:ok, %Member{role: role} = member}} when role != :rejected <- {:is_member, {:ok, %Member{role: role} = member}} when role != :rejected <-
{:is_member, Actors.get_member(person_id, group_id)} do {:is_member, Actors.get_member(person_id, group_id)} do
Actions.Remove.remove(member, group, moderator, false) Actions.Remove.remove(member, group, moderator, false)
@ -866,6 +865,9 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:error, _err} -> {:error, _err} ->
case get_member(join_object) do case get_member(join_object) do
{:ok, %Member{role: :not_approved} = member} ->
do_handle_incoming_accept_join_group(member, :member, %{moderator: actor_accepting})
{:ok, %Member{invited_by: nil} = member} -> {:ok, %Member{invited_by: nil} = member} ->
do_handle_incoming_accept_join_group(member, :join) do_handle_incoming_accept_join_group(member, :join)
@ -922,15 +924,17 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
defp do_handle_incoming_accept_join_group( defp do_handle_incoming_accept_join_group(
%Member{role: role, parent: _group} = member, %Member{role: role, parent: _group} = member,
type type,
additional \\ %{}
) )
when role in [:not_approved, :rejected, :invited] and type in [:join, :invite] do when role in [:not_approved, :rejected, :invited] and type in [:join, :invite, :member] do
# Or maybe for groups it's the group that sends the Accept activity # Or maybe for groups it's the group that sends the Accept activity
with {:ok, %Activity{} = activity, %Member{role: :member} = member} <- with {:ok, %Activity{} = activity, %Member{role: :member} = member} <-
Actions.Accept.accept( Actions.Accept.accept(
type, type,
member, member,
false false,
additional
) do ) do
{:ok, activity, member} {:ok, activity, member}
end end
@ -1194,4 +1198,17 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Actions.Create.create(:discussion, object_data, false) Actions.Create.create(:discussion, object_data, false)
end end
end end
@spec can_remove_actor_from_group?(Actor.t(), Actor.t()) :: boolean()
defp can_remove_actor_from_group?(%Actor{} = moderator, %Actor{} = group) do
case Actors.get_member(moderator.id, group.id) do
{:ok, %Member{role: role}} when role in [:moderator, :administrator, :creator] ->
true
_ ->
# If member moderator not found, it's probably because no one on this instance is member of this group yet
# Therefore we can't access the list of admin/moderators and we just trust the origin domain
moderator.domain == group.domain
end
end
end end

View file

@ -151,7 +151,13 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1))) |> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
}) do }) do
{:ok, %Member{} = member} -> {:ok, %Member{} = member} ->
Mobilizon.Service.Activity.Member.insert_activity(member, subject: "member_joined") subject =
case Mobilizon.Actors.get_default_member_role(group) do
:not_approved -> "member_request"
:member -> "member_joined"
end
Mobilizon.Service.Activity.Member.insert_activity(member, subject: subject)
Absinthe.Subscription.publish(Endpoint, actor, Absinthe.Subscription.publish(Endpoint, actor,
group_membership_changed: [Actor.preferred_username_and_domain(group), actor.id] group_membership_changed: [Actor.preferred_username_and_domain(group), actor.id]

View file

@ -141,6 +141,37 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
end end
end end
def approve_member(_parent, %{member_id: member_id}, %{
context: %{current_actor: %Actor{} = moderator}
}) do
case Actors.get_member(member_id) do
%Member{} = member ->
with {:ok, _activity, %Member{} = member} <-
Actions.Accept.accept(:member, member, true, %{moderator: moderator}) do
{:ok, member}
end
{:error, :member_not_found} ->
{:error, dgettext("errors", "You are not a moderator or admin for this group")}
end
end
# TODO : Maybe remove me ? Remove member with exclude parameter does the same
def reject_member(_parent, %{member_id: member_id}, %{
context: %{current_actor: %Actor{} = moderator}
}) do
case Actors.get_member(member_id) do
%Member{} = member ->
with {:ok, _activity, %Member{} = member} <-
Actions.Reject.reject(:member, member, true, %{moderator: moderator}) do
{:ok, member}
end
{:error, :member_not_found} ->
{:error, dgettext("errors", "You are not a moderator or admin for this group")}
end
end
@spec update_member(any(), map(), Absinthe.Resolution.t()) :: @spec update_member(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Member.t()} | {:error, String.t()} {:ok, Member.t()} | {:error, String.t()}
def update_member(_parent, %{member_id: member_id, role: role}, %{ def update_member(_parent, %{member_id: member_id, role: role}, %{
@ -168,18 +199,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
@spec remove_member(any(), map(), Absinthe.Resolution.t()) :: @spec remove_member(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Member.t()} | {:error, String.t()} {:ok, Member.t()} | {:error, String.t()}
def remove_member(_parent, %{member_id: member_id, group_id: group_id}, %{ def remove_member(_parent, %{member_id: member_id, exclude: _exclude}, %{
context: %{current_actor: %Actor{id: moderator_id} = moderator} context: %{current_actor: %Actor{id: moderator_id} = moderator}
}) do }) do
with %Member{role: role} = member when role != :rejected <- Actors.get_member(member_id), case Actors.get_member(member_id) do
%Actor{type: :Group} = group <- Actors.get_actor(group_id), nil ->
{:has_rights_to_remove, {:ok, %Member{role: role}}} {:error,
when role in [:moderator, :administrator, :creator] <- dgettext(
{:has_rights_to_remove, Actors.get_member(moderator_id, group_id)}, "errors",
{:ok, _activity, %Member{}} <- "This member does not exist"
Actions.Remove.remove(member, group, moderator, true) do )}
{:ok, member}
else
%Member{role: :rejected} -> %Member{role: :rejected} ->
{:error, {:error,
dgettext( dgettext(
@ -187,7 +217,24 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
"This member already has been rejected." "This member already has been rejected."
)} )}
{:has_rights_to_remove, _} -> %Member{parent_id: group_id} = member ->
case Actors.get_member(moderator_id, group_id) do
{:ok, %Member{role: role}} when role in [:moderator, :administrator, :creator] ->
%Actor{type: :Group} = group = Actors.get_actor(group_id)
with {:ok, _activity, %Member{}} <-
Actions.Remove.remove(member, group, moderator, true) do
{:ok, member}
end
{:ok, %Member{}} ->
{:error,
dgettext(
"errors",
"You don't have the role needed to remove this member."
)}
{:error, :member_not_found} ->
{:error, {:error,
dgettext( dgettext(
"errors", "errors",
@ -195,6 +242,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
)} )}
end end
end end
end
def remove_member(_parent, _args, _resolution),
do:
{:error,
dgettext(
"errors",
"You must be logged-in to remove a member"
)}
# Rejected members can be invited again # Rejected members can be invited again
@spec check_member_not_existant_or_rejected(String.t() | integer, String.t() | integer()) :: @spec check_member_not_existant_or_rejected(String.t() | integer, String.t() | integer()) ::

View file

@ -81,6 +81,24 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
resolve(&Member.reject_invitation/3) resolve(&Member.reject_invitation/3)
end end
@desc """
Approve a membership request
"""
field :approve_member, :member do
arg(:member_id, non_null(:id), description: "The member ID")
resolve(&Member.approve_member/3)
end
@desc """
Reject a membership request
"""
field :reject_member, :member do
arg(:member_id, non_null(:id), description: "The member ID")
resolve(&Member.reject_member/3)
end
@desc """ @desc """
Update a member's role Update a member's role
""" """
@ -93,9 +111,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
@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), description: "The group ID")
arg(:member_id, non_null(:id), description: "The member ID") arg(:member_id, non_null(:id), description: "The member ID")
arg(:exclude, :boolean,
default_value: false,
description: "Whether the member should be excluded from the group"
)
resolve(&Member.remove_member/3) resolve(&Member.remove_member/3)
end end
end end

View file

@ -50,6 +50,14 @@ defmodule Mobilizon.Service.Activity.Renderer.Member do
dgettext("activity", "%{profile} added the member %{member}.", args) dgettext("activity", "%{profile} added the member %{member}.", args)
end end
defp text(:member_approved, args) do
dgettext("activity", "%{profile} approved the membership request from %{member}.", args)
end
defp text(:member_rejected, args) do
dgettext("activity", "%{profile} rejected the membership request from %{member}.", args)
end
defp text(:member_updated, args) do defp text(:member_updated, args) do
dgettext("activity", "%{profile} updated the member %{member}.", args) dgettext("activity", "%{profile} updated the member %{member}.", args)
end end

View file

@ -247,7 +247,7 @@ defmodule Mobilizon.Service.Notifications.Scheduler do
:direct :direct
:one_day -> :one_day ->
calculate_next_day_notification(Date.utc_today(), timezone) calculate_next_day_notification(Date.utc_today(), timezone: timezone)
:one_hour -> :one_hour ->
DateTime.utc_now() DateTime.utc_now()

View file

@ -46,13 +46,65 @@ defmodule Mobilizon.Web.Email.Member do
end end
end end
# Only send notification to local members
def send_notification_to_approved_member(%Member{actor: %Actor{user_id: nil}}), do: :ok
def send_notification_to_approved_member(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{} = group
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
subject =
gettext(
"Your membership request for group %{group} has been approved",
group: Actor.display_name(group)
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_membership_approval)
|> Email.Mailer.send_email_later()
:ok
end
end
# Only send notification to local members # Only send notification to local members
def send_notification_to_removed_member(%Member{actor: %Actor{user_id: nil}}), do: :ok def send_notification_to_removed_member(%Member{actor: %Actor{user_id: nil}}), do: :ok
# Member rejection
def send_notification_to_removed_member(%Member{ def send_notification_to_removed_member(%Member{
actor: %Actor{user_id: user_id}, actor: %Actor{user_id: user_id},
parent: %Actor{} = group, parent: %Actor{} = group,
role: :rejected role: :not_approved
}) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
subject =
gettext(
"Your membership request for group %{group} has been rejected",
group: Actor.display_name(group)
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_membership_rejection)
|> Email.Mailer.send_email_later()
:ok
end
end
def send_notification_to_removed_member(%Member{
actor: %Actor{user_id: user_id},
parent: %Actor{} = group
}) do }) do
with %User{email: email, locale: locale} <- Users.get_user!(user_id) do with %User{email: email, locale: locale} <- Users.get_user!(user_id) do
Gettext.put_locale(locale) Gettext.put_locale(locale)
@ -60,7 +112,7 @@ defmodule Mobilizon.Web.Email.Member do
subject = subject =
gettext( gettext(
"You have been removed from group %{group}", "You have been removed from group %{group}",
group: group.name group: Actor.display_name(group)
) )
Email.base_email(to: email, subject: subject) Email.base_email(to: email, subject: subject)

View file

@ -0,0 +1,69 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "You're in!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Your membership to group %{link_start}<b>%{group}</b>%{link_end} has been approved.", group: Mobilizon.Actors.Actor.display_name(@group), link_start: "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group))}\">", link_end: "</a>") |> raw %>
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3C376E">
<a href={"#{ Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group)) }"} target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;">
<%= gettext "View the group" %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,5 @@
<%= gettext "You're in!" %>
==
<%= gettext "Your membership to group %{group} has been approved.", group: Mobilizon.Actors.Actor.display_name(@group) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group)) %>

View file

@ -0,0 +1,49 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Sorry, not this time!" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Your membership request to join group %{link_start}<b>%{group}</b>%{link_end} has been rejected.", group: Mobilizon.Actors.Actor.display_name(@group), link_start: "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group))}\">", link_end: "</a>") |> raw %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,4 @@
<%= gettext "Sorry, not this time!" %>
==
<%= gettext "Your membership request to join group %{group} has been rejected.", group: Mobilizon.Actors.Actor.display_name(@group) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(@group)) %>

View file

@ -203,7 +203,7 @@ defmodule Mobilizon.Federation.ActivityPubTest do
{:ok, activity, _member} = Actions.Remove.remove(member, group, moderator, true) {:ok, activity, _member} = Actions.Remove.remove(member, group, moderator, true)
assert activity.data["type"] == "Remove" assert activity.data["type"] == "Remove"
assert activity.data["actor"] == moderator.url assert activity.data["actor"] == moderator.url
assert activity.data["to"] == [group.members_url] assert activity.data["to"] == [member.actor.url, group.members_url]
assert activity.data["object"] == member.url assert activity.data["object"] == member.url
end end
end end

View file

@ -524,6 +524,93 @@ defmodule Mobilizon.GraphQL.Resolvers.MemberTest do
end end
describe "Member resolver to remove a member from a group" do describe "Member resolver to remove a member from a group" do
# TODO write tests for me plz @remove_member_mutation """
mutation RemoveMember($memberId: ID!) {
removeMember(memberId: $memberId) {
id
}
}
"""
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 "remove_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: @remove_member_mutation,
variables: %{
memberId: member_id
}
)
assert hd(res["errors"])["message"] == "You must be logged-in to remove a member"
end
test "remove_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, actor)
%Member{id: member_id} =
insert(:member, %{actor: target_actor, parent: group, role: :member})
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @remove_member_mutation,
variables: %{
memberId: member_id
}
)
assert hd(res["errors"])["message"] == "You don't have the right to remove this member."
end
test "remove_member/3 removes a member", %{
conn: conn,
user: user,
actor: actor,
group: group,
target_actor: target_actor
} do
Mobilizon.Users.update_user_default_actor(user, actor)
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: @remove_member_mutation,
variables: %{
memberId: member_id
}
)
assert res["errors"] == nil
assert res["data"]["removeMember"]["id"] == member_id
assert Mobilizon.Actors.get_member(member_id) == nil
end
end end
end end