Merge branch 'group-admin-profiles' into 'master'

Add group admin profiles

See merge request framasoft/mobilizon!551
This commit is contained in:
Thomas Citharel 2020-08-27 12:28:07 +02:00
commit c5937bbccc
107 changed files with 3514 additions and 1146 deletions

View File

@ -1,24 +1,14 @@
@import "variables.scss";
a {
color: $violet-2;
}
a.out,
.content a {
text-decoration: underline;
text-decoration-color: #ed8d07;
text-decoration-thickness: 2px;
&.navbar-item,
&.dropdown-item,
&.card,
&.button,
&[href="#comments"],
&.router-link-active,
&.comment-link,
&.pagination-link,
&.datepicker-cell,
&.list-item {
text-decoration: none;
}
}
nav.breadcrumb ul li a {
text-decoration: none;
}
input.input {

View File

@ -15,8 +15,8 @@
<div class="title-info-wrapper">
<div class="title-and-date">
<p class="discussion-minimalist-title">{{ discussion.title }}</p>
<span :title="discussion.updatedAt | formatDateTimeString">
{{ $timeAgo.format(new Date(discussion.updatedAt), "twitter") || $t("Right now") }}</span
<span :title="actualDate | formatDateTimeString">
{{ $timeAgo.format(new Date(actualDate), "twitter") || $t("Right now") }}</span
>
</div>
<div class="has-text-grey" v-if="!discussion.lastComment.deletedAt">
@ -46,6 +46,13 @@ export default class DiscussionListItem extends Vue {
}
return element.innerText;
}
get actualDate() {
if (this.discussion.updatedAt === this.discussion.insertedAt && this.discussion.lastComment) {
return this.discussion.lastComment.publishedAt;
}
return this.discussion.updatedAt;
}
}
</script>
<style lang="scss" scoped>

View File

@ -37,6 +37,7 @@
<SettingMenuItem :title="$t('Moderation log')" :to="{ name: RouteName.REPORT_LOGS }" />
<SettingMenuItem :title="$t('Users')" :to="{ name: RouteName.USERS }" />
<SettingMenuItem :title="$t('Profiles')" :to="{ name: RouteName.PROFILES }" />
<SettingMenuItem :title="$t('Groups')" :to="{ name: RouteName.ADMIN_GROUPS }" />
</SettingMenuSection>
<SettingMenuSection
v-if="this.currentUser.role == ICurrentUserRole.ADMINISTRATOR"

View File

@ -5,6 +5,7 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
id
title
slug
insertedAt
updatedAt
lastComment {
id
@ -16,6 +17,7 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
url
}
}
publishedAt
deletedAt
}
}
@ -114,6 +116,7 @@ export const GET_DISCUSSION = gql`
insertedAt
updatedAt
deletedAt
publishedAt
}
}
...DiscussionFields

View File

@ -4,8 +4,24 @@ import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "./resources";
import { POST_BASIC_FIELDS } from "./post";
export const LIST_GROUPS = gql`
query {
groups {
query ListGroups(
$preferredUsername: String
$name: String
$domain: String
$local: Boolean
$suspended: Boolean
$page: Int
$limit: Int
) {
groups(
preferredUsername: $preferredUsername
name: $name
domain: $domain
local: $local
suspended: $suspended
page: $page
limit: $limit
) {
elements {
id
url
@ -34,109 +50,128 @@ export const LIST_GROUPS = gql`
}
`;
export const GROUP_FIELDS_FRAGMENTS = gql`
fragment GroupFullFields on Group {
id
url
name
domain
summary
preferredUsername
suspended
visibility
physicalAddress {
description
street
locality
postalCode
region
country
geom
type
id
originId
}
avatar {
url
}
banner {
url
}
organizedEvents {
elements {
id
uuid
title
beginsOn
}
total
}
discussions {
total
elements {
...DiscussionBasicFields
}
}
posts {
total
elements {
...PostBasicFields
}
}
members {
elements {
role
actor {
id
name
domain
preferredUsername
avatar {
url
}
}
insertedAt
}
total
}
resources(page: 1, limit: 3) {
elements {
id
title
resourceUrl
summary
updatedAt
type
path
metadata {
...ResourceMetadataBasicFields
}
}
total
}
todoLists {
elements {
id
title
todos {
elements {
id
title
status
dueDate
assignedTo {
id
preferredUsername
}
}
total
}
}
total
}
}
`;
export const FETCH_GROUP = gql`
query($name: String!) {
group(preferredUsername: $name) {
id
url
name
domain
summary
preferredUsername
suspended
visibility
physicalAddress {
description
street
locality
postalCode
region
country
geom
type
id
originId
}
avatar {
url
}
banner {
url
}
organizedEvents {
elements {
id
uuid
title
beginsOn
}
total
}
discussions {
total
elements {
...DiscussionBasicFields
}
}
posts {
total
elements {
...PostBasicFields
}
}
members {
elements {
role
actor {
id
name
domain
preferredUsername
avatar {
url
}
}
insertedAt
}
total
}
resources(page: 1, limit: 3) {
elements {
id
title
resourceUrl
summary
updatedAt
type
path
metadata {
...ResourceMetadataBasicFields
}
}
total
}
todoLists {
elements {
id
title
todos {
elements {
id
title
status
dueDate
assignedTo {
id
preferredUsername
}
}
total
}
}
total
}
...GroupFullFields
}
}
${GROUP_FIELDS_FRAGMENTS}
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
${POST_BASIC_FIELDS}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
`;
export const GET_GROUP = gql`
query($id: ID!) {
getGroup(id: $id) {
...GroupFullFields
}
}
${GROUP_FIELDS_FRAGMENTS}
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
${POST_BASIC_FIELDS}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
@ -162,6 +197,7 @@ export const CREATE_GROUP = gql`
id
preferredUsername
name
domain
summary
avatar {
url
@ -206,6 +242,14 @@ export const UPDATE_GROUP = gql`
}
`;
export const DELETE_GROUP = gql`
mutation DeleteGroup($groupId: ID!) {
deleteGroup(groupId: $groupId) {
id
}
}
`;
export const LEAVE_GROUP = gql`
mutation LeaveGroup($groupId: ID!) {
leaveGroup(groupId: $groupId) {
@ -213,3 +257,11 @@ export const LEAVE_GROUP = gql`
}
}
`;
export const REFRESH_PROFILE = gql`
mutation RefreshProfile($actorId: ID!) {
refreshProfile(id: $actorId) {
id
}
}
`;

View File

@ -160,7 +160,6 @@
"Go": "Go",
"Going as {name}": "Going as {name}",
"Group List": "Group List",
"Group full name": "Group full name",
"Group name": "Group name",
"Group {displayName} created": "Group {displayName} created",
"Groups": "Groups",
@ -766,5 +765,19 @@
"Edited {ago}": "Edited {ago}",
"[This comment has been deleted by it's author]": "[This comment has been deleted by it's author]",
"Promote": "Promote",
"Demote": "Demote"
"Demote": "Demote",
"{number} members": "{number} members",
"{number} posts": "No posts|One post|{number} posts",
"Publication date": "Publication date",
"Refresh profile": "Refresh profile",
"Suspend group": "Suspend group",
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.",
"Delete group": "Delete group",
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.",
"Group display name": "Group display name",
"Federated Group Name": "Federated Group Name",
"This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.": "This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.",
"Banner": "Banner",
"A group with this name already exists": "A group with this name already exists"
}

View File

@ -243,11 +243,10 @@
"Group List": "Liste de groupes",
"Group Members": "Membres du groupe",
"Group address": "Adresse du groupe",
"Group full name": "Nom complet du groupe",
"Group name": "Nom du groupe",
"Group settings": "Paramètres du groupe",
"Group short description": "Description courte du groupe",
"Group visibility": "Visibility du groupe",
"Group visibility": "Visibilité du groupe",
"Group {displayName} created": "Groupe {displayName} créé",
"Groups": "Groupes",
"Headline picture": "Image à la une",
@ -301,7 +300,7 @@
"Language": "Langue",
"Last published event": "Dernier évènement publié",
"Last week": "La semaine dernière",
"Latest posts": "Derniers messages publics",
"Latest posts": "Derniers billets",
"Learn more": "En apprendre plus",
"Learn more about Mobilizon": "En apprendre plus à propos de Mobilizon",
"Leave event": "Annuler ma participation à l'évènement",
@ -767,5 +766,19 @@
"Edited {ago}": "Édité {ago}",
"[This comment has been deleted by it's author]": "[Ce commentaire a été supprimé par son auteur]",
"Promote": "Promouvoir",
"Demote": "Rétrograder"
"Demote": "Rétrograder",
"{number} members": "{number} membres",
"{number} posts": "Aucun billet|Un billet|{number} billets",
"Publication date": "Date de publication",
"Refresh profile": "Rafraîchir le profil",
"Suspend group": "Suspendre le groupe",
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Comme ce groupe provient de l'instance {instance}, cela supprimera seulement les membres locaux et supprimera les données locales, et rejettera également toutes les données futures.",
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Tous les membres - y compris celleux sur d'autres instances - seront notifié·es et supprimé·es du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
"Delete group": "Supprimer le groupe",
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>complètement supprimer</b> ce groupe ? Tous les membres - y compris celleux sur d'autres instances - seront notifié·es et supprimé·es du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
"Group display name": "Nom d'affichage du groupe",
"Federated Group Name": "Nom fédéré du groupe",
"This is like your federated username (<code>{username}</code>) for groups. It will allow you to be found on the federation, and is guaranteed to be unique.": "C'est comme votre addresse fédérée (<code>{username}</code>) pour les groupes. Cela vous permettra d'être trouvable sur la fédération, et est garanti d'être unique.",
"Banner": "Bannière",
"A group with this name already exists": "Un groupe avec ce nom existe déjà"
}

View File

@ -1,7 +1,8 @@
import { Component, Mixins, Vue } from "vue-property-decorator";
import { Person } from "@/types/actor";
@Component({})
// TODO: Refactor into js/src/utils/username.ts
@Component
export default class IdentityEditionMixin extends Mixins(Vue) {
identity: Person = new Person();

View File

@ -16,6 +16,8 @@ import Users from "../views/Admin/Users.vue";
import Profiles from "../views/Admin/Profiles.vue";
import AdminProfile from "../views/Admin/AdminProfile.vue";
import AdminUserProfile from "../views/Admin/AdminUserProfile.vue";
import GroupProfiles from "../views/Admin/GroupProfiles.vue";
import AdminGroupProfile from "../views/Admin/AdminGroupProfile.vue";
export enum SettingsRouteName {
SETTINGS = "SETTINGS",
@ -33,6 +35,8 @@ export enum SettingsRouteName {
PROFILES = "PROFILES",
ADMIN_PROFILE = "ADMIN_PROFILE",
ADMIN_USER_PROFILE = "ADMIN_USER_PROFILE",
ADMIN_GROUPS = "ADMIN_GROUPS",
ADMIN_GROUP_PROFILE = "ADMIN_GROUP_PROFILE",
MODERATION = "MODERATION",
REPORTS = "Reports",
REPORT = "Report",
@ -123,6 +127,20 @@ export const settingsRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/groups",
name: SettingsRouteName.ADMIN_GROUPS,
component: GroupProfiles,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/groups/:id",
name: SettingsRouteName.ADMIN_GROUP_PROFILE,
component: AdminGroupProfile,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/relays",
name: SettingsRouteName.RELAYS,

View File

@ -60,9 +60,11 @@ export class Actor implements IActor {
}
}
export function usernameWithDomain(actor: IActor): string {
export function usernameWithDomain(actor: IActor, force = false): string {
if (actor.domain) {
return `${actor.preferredUsername}@${actor.domain}`;
} else if (force) {
return `${actor.preferredUsername}@${window.location.hostname}`;
}
return actor.preferredUsername;
}

View File

@ -16,6 +16,7 @@ export interface IComment {
deletedAt?: Date | string;
totalReplies: number;
insertedAt?: Date | string;
publishedAt?: Date | string;
}
export class CommentModel implements IComment {

View File

@ -10,6 +10,8 @@ export interface IDiscussion {
actor?: IActor;
lastComment?: IComment;
comments: Paginate<IComment>;
updatedAt: string;
insertedAt: string;
}
export class Discussion implements IDiscussion {
@ -27,6 +29,10 @@ export class Discussion implements IDiscussion {
lastComment?: IComment = undefined;
insertedAt: string = "";
updatedAt: string = "";
constructor(hash?: IDiscussion) {
if (!hash) return;
@ -40,5 +46,7 @@ export class Discussion implements IDiscussion {
this.creator = hash.creator;
this.actor = hash.actor;
this.lastComment = hash.lastComment;
this.insertedAt = hash.insertedAt;
this.updatedAt = hash.updatedAt;
}
}

29
js/src/utils/username.ts Normal file
View File

@ -0,0 +1,29 @@
import { IActor } from "@/types/actor";
function autoUpdateUsername(actor: IActor, newDisplayName: string | null): IActor {
const oldUsername = convertToUsername(actor.name);
if (actor.preferredUsername === oldUsername) {
actor.preferredUsername = convertToUsername(newDisplayName);
}
return actor;
}
function convertToUsername(value: string | null): string {
if (!value) return "";
// https://stackoverflow.com/a/37511463
return value
.toLocaleLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ /g, "_")
.replace(/[^a-z0-9_]/g, "");
}
function validateUsername(actor: IActor): boolean {
return actor.preferredUsername === convertToUsername(actor.preferredUsername);
}
export { autoUpdateUsername, convertToUsername, validateUsername };

View File

@ -50,11 +50,11 @@
</form>
<div v-if="validationSent && !userAlreadyActivated">
<b-message title="Success" type="is-success" closable="false">
<b-message type="is-success" closable="false">
<h2 class="title">
{{
$t("Your account is nearly ready, {username}", {
username: identity.preferredUsername,
username: identity.name || identity.preferredUsername,
})
}}
</h2>

View File

@ -150,6 +150,7 @@ import identityEditionMixin from "../../../mixins/identityEdition";
},
identity: {
query: FETCH_PERSON,
fetchPolicy: "cache-and-network",
variables() {
return {
username: this.identityName,

View File

@ -0,0 +1,436 @@
<template>
<div v-if="group" class="section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{ $t("Admin") }}</router-link>
</li>
<li>
<router-link
:to="{
name: RouteName.ADMIN_GROUPS,
}"
>{{ $t("Groups") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.PROFILES,
params: { id: group.id },
}"
>{{ group.name || group.preferredUsername }}</router-link
>
</li>
</ul>
</nav>
<div class="actor-card">
<router-link
:to="{ name: RouteName.GROUP, params: { preferredUsername: usernameWithDomain(group) } }"
>
<actor-card :actor="group" :full="true" :popover="false" :limit="false" />
</router-link>
</div>
<table v-if="metadata.length > 0" class="table is-fullwidth">
<tbody>
<tr v-for="{ key, value, link } in metadata" :key="key">
<td>{{ key }}</td>
<td v-if="link">
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td v-else>{{ value }}</td>
</tr>
</tbody>
</table>
<div class="buttons">
<b-button @click="confirmSuspendProfile" v-if="!group.suspended" type="is-primary">{{
$t("Suspend")
}}</b-button>
<b-button @click="unsuspendProfile" v-if="group.suspended" type="is-primary">{{
$t("Unsuspend")
}}</b-button>
<b-button @click="refreshProfile" v-if="group.domain" type="is-primary" outlined>{{
$t("Refresh profile")
}}</b-button>
</div>
<section>
<h2 class="subtitle">
{{
$tc("{number} members", group.members.total, {
number: group.members.total,
})
}}
</h2>
<b-table
:data="group.members.elements"
:loading="$apollo.queries.group.loading"
paginated
backend-pagination
:total="group.members.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onMembersPageChange"
>
<b-table-column field="actor.preferredUsername" :label="$t('Member')" v-slot="props">
<article class="media">
<figure class="media-left image is-48x48" v-if="props.row.actor.avatar">
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
<b-tag type="is-primary" v-if="props.row.role === MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
</b-tag>
<b-tag type="is-primary" v-else-if="props.row.role === MemberRole.MODERATOR">
{{ $t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
</b-tag>
<b-tag type="is-warning" v-else-if="props.row.role === MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.REJECTED">
{{ $t("Rejected") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.INVITED">
{{ $t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}
</span>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("Nothing to see here") }}</p>
</div>
</section>
</template>
</b-table>
</section>
<section>
<h2 class="subtitle">
{{
$tc("{number} organized events", group.organizedEvents.total, {
number: group.organizedEvents.total,
})
}}
</h2>
<b-table
:data="group.organizedEvents.elements"
:loading="$apollo.queries.group.loading"
paginated
backend-pagination
:total="group.organizedEvents.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onOrganizedEventsPageChange"
>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }">
{{ props.row.title }}
</router-link>
</b-table-column>
<b-table-column field="beginsOn" :label="$t('Begins on')" v-slot="props">
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("Nothing to see here") }}</p>
</div>
</section>
</template>
</b-table>
</section>
<section>
<h2 class="subtitle">
{{
$tc("{number} posts", group.posts.total, {
number: group.posts.total,
})
}}
</h2>
<b-table
:data="group.posts.elements"
:loading="$apollo.queries.group.loading"
paginated
backend-pagination
:total="group.posts.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onPostsPageChange"
>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
<router-link :to="{ name: RouteName.POST, params: { slug: props.row.slug } }">
{{ props.row.title }}
</router-link>
</b-table-column>
<b-table-column field="publishAt" :label="$t('Publication date')" v-slot="props">
{{ props.row.publishAt | formatDateTimeString }}
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("Nothing to see here") }}</p>
</div>
</section>
</template>
</b-table>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IGroup, MemberRole } from "../../types/actor";
import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IEvent } from "../../types/event.model";
import ActorCard from "../../components/Account/ActorCard.vue";
import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
const EVENTS_PER_PAGE = 10;
@Component({
apollo: {
group: {
query: GET_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.id,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
};
},
skip() {
return !this.id;
},
update: (data) => data.getGroup,
},
},
components: {
ActorCard,
},
})
export default class AdminGroupProfile extends Vue {
@Prop({ required: true }) id!: string;
group!: IGroup;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
organizedEventsPage = 1;
membersPage = 1;
postsPage = 1;
MemberRole = MemberRole;
get metadata(): Array<object> {
if (!this.group) return [];
const res: object[] = [
{
key: this.$t("Status") as string,
value: this.group.suspended ? this.$t("Suspended") : this.$t("Active"),
},
{
key: this.$t("Domain") as string,
value: this.group.domain ? this.group.domain : this.$t("Local"),
},
];
return res;
}
confirmSuspendProfile() {
const message = (this.group.domain
? this.$t(
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
{ instance: this.group.domain }
)
: this.$t(
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
)) as string;
this.$buefy.dialog.confirm({
title: this.$t("Suspend group") as string,
message,
confirmText: this.$t("Suspend group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.suspendProfile(),
});
}
async suspendProfile() {
this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_PROFILE,
variables: {
id: this.id,
},
update: (store, { data }) => {
if (data == null) return;
const profileId = this.id;
const profileData = store.readQuery<{ getGroup: IGroup }>({
query: GET_GROUP,
variables: {
id: profileId,
},
});
if (!profileData) return;
const { getGroup: group } = profileData;
group.suspended = true;
group.avatar = null;
group.name = "";
group.summary = "";
store.writeQuery({
query: GET_GROUP,
variables: {
id: profileId,
},
data: { getGroup: group },
});
},
});
}
async unsuspendProfile() {
const profileID = this.id;
this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE,
variables: {
id: this.id,
},
refetchQueries: [
{
query: GET_GROUP,
variables: {
id: profileID,
},
},
],
});
}
async refreshProfile() {
this.$apollo.mutate<{ refreshProfile: IActor }>({
mutation: REFRESH_PROFILE,
variables: {
actorId: this.id,
},
});
}
async onOrganizedEventsPageChange(page: number) {
this.organizedEventsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
organizedEventsPage: this.organizedEventsPage,
organizedEventsLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newOrganizedEvents = fetchMoreResult.group.organizedEvents.elements;
return {
group: {
...previousResult.group,
organizedEvents: {
__typename: previousResult.group.organizedEvents.__typename,
total: previousResult.group.organizedEvents.total,
elements: [...previousResult.group.organizedEvents.elements, ...newOrganizedEvents],
},
},
};
},
});
}
async onMembersPageChange(page: number) {
this.membersPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
memberPage: this.membersPage,
memberLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newMembers = fetchMoreResult.group.members.elements;
return {
group: {
...previousResult.group,
members: {
__typename: previousResult.group.members.__typename,
total: previousResult.group.members.total,
elements: [...previousResult.group.members.elements, ...newMembers],
},
},
};
},
});
}
async onPostsPageChange(page: number) {
this.postsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
postsPage: this.postsPage,
postLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newPosts = fetchMoreResult.group.posts.elements;
return {
group: {
...previousResult.group,
posts: {
__typename: previousResult.group.posts.__typename,
total: previousResult.group.posts.total,
elements: [...previousResult.group.posts.elements, ...newPosts],
},
},
};
},
});
}
}
</script>
<style lang="scss" scoped>
table,
section {
margin: 2rem 0;
}
.actor-card {
background: #fff;
padding: 1.5rem;
border-radius: 10px;
}
</style>

View File

@ -139,6 +139,7 @@ const EVENTS_PER_PAGE = 10;
apollo: {
person: {
query: GET_PERSON,
fetchPolicy: "cache-and-network",
variables() {
return {
actorId: this.id,

View File

@ -69,6 +69,7 @@ import { IPerson } from "../../types/actor";
apollo: {
user: {
query: GET_USER,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.id,

View File

@ -71,6 +71,7 @@ import RouteName from "../../router/name";
apollo: {
dashboard: {
query: DASHBOARD,
fetchPolicy: "cache-and-network",
},
},
metaInfo() {

View File

@ -0,0 +1,168 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{ $t("Moderation") }}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.PROFILES }">{{ $t("Groups") }}</router-link>
</li>
</ul>
</nav>
<div v-if="groups">
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>
<b-table
:data="groups.elements"
:loading="$apollo.queries.groups.loading"
paginated
backend-pagination
backend-filtering
:total="groups.total"
:per-page="PROFILES_PER_PAGE"
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<b-table-column field="preferredUsername" :label="$t('Username')" searchable>
<template slot="searchable" slot-scope="props">
<b-input
v-model="props.filters.preferredUsername"
placeholder="Search..."
icon="magnify"
size="is-small"
/>
</template>
<template v-slot:default="props">
<router-link
class="profile"
:to="{ name: RouteName.ADMIN_GROUP_PROFILE, params: { id: props.row.id } }"
>
<article class="media">
<figure class="media-left" v-if="props.row.avatar">
<p class="image is-48x48">
<img :src="props.row.avatar.url" />
</p>
</figure>
<div class="media-content">
<div class="content">
<strong v-if="props.row.name">{{ props.row.name }}</strong
><br v-if="props.row.name" />
<small>@{{ props.row.preferredUsername }}</small>
</div>
</div>
</article>
</router-link>
</template>
</b-table-column>
<b-table-column field="domain" :label="$t('Domain')" searchable>
<template slot="searchable" slot-scope="props">
<b-input
v-model="props.filters.domain"
placeholder="Search..."
icon="magnify"
size="is-small"
/>
</template>
<template v-slot:default="props">
{{ props.row.domain }}
</template>
</b-table-column>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("No profile matches the filters") }}</p>
</div>
</section>
</template>
</b-table>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { LIST_PROFILES } from "../../graphql/actor";
import RouteName from "../../router/name";
import { LIST_GROUPS } from "@/graphql/group";
const PROFILES_PER_PAGE = 10;
@Component({
apollo: {
groups: {
query: LIST_GROUPS,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: 1,
limit: PROFILES_PER_PAGE,
};
},
},
},
})
export default class GroupProfiles extends Vue {
page = 1;
preferredUsername = "";
name = "";
domain = "";
local = true;
suspended = false;
PROFILES_PER_PAGE = PROFILES_PER_PAGE;
RouteName = RouteName;
async onPageChange(page: number) {
this.page = page;
await this.$apollo.queries.groups.fetchMore({
variables: {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: this.page,
limit: PROFILES_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newProfiles = fetchMoreResult.groups.elements;
return {
groups: {
__typename: previousResult.groups.__typename,
total: previousResult.groups.total,
elements: [...previousResult.groups.elements, ...newProfiles],
},
};
},
});
}
onFiltersChange({ preferredUsername, domain }: { preferredUsername: string; domain: string }) {
this.preferredUsername = preferredUsername;
this.domain = domain;
}
@Watch("domain")
domainNotLocal() {
this.local = this.domain === "";
}
}
</script>
<style lang="scss" scoped>
a.profile {
text-decoration: none;
}
</style>

View File

@ -91,6 +91,7 @@ const PROFILES_PER_PAGE = 10;
apollo: {
persons: {
query: LIST_PROFILES,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,

View File

@ -103,6 +103,7 @@ const USERS_PER_PAGE = 10;
apollo: {
users: {
query: LIST_USERS,
fetchPolicy: "cache-and-network",
variables() {
return {
email: this.email,

View File

@ -120,6 +120,7 @@ import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
apollo: {
discussion: {
query: GET_DISCUSSION,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,

View File

@ -578,6 +578,7 @@ import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
apollo: {
event: {
query: FETCH_EVENT,
fetchPolicy: "cache-and-network",
variables() {
return {
uuid: this.uuid,
@ -592,6 +593,7 @@ import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
fetchPolicy: "cache-and-network",
variables() {
return {
eventId: this.event.id,

View File

@ -2,41 +2,64 @@
<section class="section container">
<h1>{{ $t("Create a new group") }}</h1>
<div>
<b-field :label="$t('Group name')">
<b-input aria-required="true" required v-model="group.preferredUsername" />
</b-field>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
{{ value }}
</b-message>
<b-field :label="$t('Group full name')">
<form @submit.prevent="createGroup">
<b-field :label="$t('Group display name')">
<b-input aria-required="true" required v-model="group.name" />
</b-field>
<div class="field">
<label class="label">{{ $t("Federated Group Name") }}</label>
<div class="field-body">
<b-field>
<b-input aria-required="true" required expanded v-model="group.preferredUsername" />
<p class="control">
<span class="button is-static">@{{ host }}</span>
</p>
</b-field>
</div>
<p
v-html="
$t(