Add admin dashboard, event reporting, moderation report screens, moderation log

Close #156 and #158

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2019-09-09 09:31:08 +02:00
parent 164429964a
commit 27f2597b07
77 changed files with 1682 additions and 201 deletions

View File

@@ -0,0 +1,73 @@
<template>
<section class="container">
<h1 class="title">Administration</h1>
<div class="tile is-ancestor" v-if="dashboard">
<div class="tile is-vertical is-4">
<div class="tile">
<div class="tile is-parent is-vertical is-6">
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfEvents }}</p>
<p class="subtitle">événements publiés</p>
</article>
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfComments}}</p>
<p class="subtitle">commentaires</p>
</article>
</div>
<div class="tile is-parent is-vertical">
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfUsers }}</p>
<p class="subtitle">utilisateurices</p>
</article>
<router-link :to="{ name: ModerationRouteName.REPORTS}">
<article class="tile is-child box">
<p class="title">{{ dashboard.numberOfReports }}</p>
<p class="subtitle">signalements ouverts</p>
</article>
</router-link>
</div>
</div>
<div class="tile is-parent" v-if="dashboard.lastPublicEventPublished">
<article class="tile is-child box">
<p class="title">Dernier événement publié</p>
<p class="subtitle">{{ dashboard.lastPublicEventPublished.title }}</p>
<figure class="image is-4by3" v-if="dashboard.lastPublicEventPublished.picture">
<img :src="dashboard.lastPublicEventPublished.picture.url" />
</figure>
</article>
</div>
</div>
<div class="tile is-parent">
<article class="tile is-child box">
<div class="content">
<p class="title">Bienvenue sur votre espace d'administration</p>
<p class="subtitle">With even more content</p>
<div class="content">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam semper diam at erat pulvinar, at pulvinar felis blandit. Vestibulum volutpat tellus diam, consequat gravida libero rhoncus ut. Morbi maximus, leo sit amet vehicula eleifend, nunc dui porta orci, quis semper odio felis ut quam.</p>
<p>Suspendisse varius ligula in molestie lacinia. Maecenas varius eget ligula a sagittis. Pellentesque interdum, nisl nec interdum maximus, augue diam porttitor lorem, et sollicitudin felis neque sit amet erat. Maecenas imperdiet felis nisi, fringilla luctus felis hendrerit sit amet. Aenean vitae gravida diam, finibus dignissim turpis. Sed eget varius ligula, at volutpat tortor.</p>
<p>Integer sollicitudin, tortor a mattis commodo, velit urna rhoncus erat, vitae congue lectus dolor consequat libero. Donec leo ligula, maximus et pellentesque sed, gravida a metus. Cras ullamcorper a nunc ac porta. Aliquam ut aliquet lacus, quis faucibus libero. Quisque non semper leo.</p>
</div>
</div>
</article>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { DASHBOARD } from '@/graphql/admin';
import { IDashboard } from '@/types/admin.model';
import { ModerationRouteName } from '@/router/moderation';
@Component({
apollo: {
dashboard: {
query: DASHBOARD,
},
},
})
export default class Dashboard extends Vue {
dashboard!: IDashboard;
ModerationRouteName = ModerationRouteName;
}
</script>

View File

@@ -53,8 +53,8 @@
</p>
</div>
<div class="column sidebar">
<div class="field has-addons" v-if="actorIsOrganizer()">
<p class="control">
<div class="field has-addons">
<p class="control" v-if="actorIsOrganizer()">
<router-link
class="button"
:to="{ name: 'EditEvent', params: {eventId: event.uuid}}"
@@ -62,11 +62,16 @@
<translate>Edit</translate>
</router-link>
</p>
<p class="control">
<p class="control" v-if="actorIsOrganizer()">
<a class="button is-danger" @click="openDeleteEventModal()">
<translate>Delete</translate>
</a>
</p>
<p class="control">
<a class="button is-danger" @click="isReportModalActive = true">
<translate>Report</translate>
</a>
</p>
</div>
<div class="address-wrapper">
<b-icon icon="map" />
@@ -224,6 +229,9 @@
</div>
</div>
</section>
<b-modal :active.sync="isReportModalActive" has-modal-card>
<report-modal :on-confirm="reportEvent" title="Report this event" :outside-domain="event.organizerActor.domain" />
</b-modal>
</div>
</div>
</template>
@@ -241,6 +249,9 @@ import BIcon from 'buefy/src/components/icon/Icon.vue';
import EventCard from '@/components/Event/EventCard.vue';
import EventFullDate from '@/components/Event/EventFullDate.vue';
import ActorLink from '@/components/Account/ActorLink.vue';
import ReportModal from '@/components/Report/ReportModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
@Component({
components: {
@@ -249,6 +260,7 @@ import ActorLink from '@/components/Account/ActorLink.vue';
EventCard,
BIcon,
DateCalendarIcon,
ReportModal,
// tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable
@@ -274,6 +286,7 @@ export default class Event extends Vue {
loggedPerson!: IPerson;
validationSent: boolean = false;
showMap: boolean = false;
isReportModalActive: boolean = false;
EventVisibility = EventVisibility;
@@ -285,24 +298,47 @@ export default class Event extends Vue {
type: 'is-danger',
title: this.$gettext('Delete event'),
message: this.$gettextInterpolate(
`${prefix}` +
'Are you sure you want to delete this event? This action cannot be reverted. <br /><br />' +
'To confirm, type your event title "%{eventTitle}"',
{ participants: this.event.participants.length, eventTitle: this.event.title },
`${prefix}` +
'Are you sure you want to delete this event? This action cannot be reverted. <br /><br />' +
'To confirm, type your event title "%{eventTitle}"',
{ participants: this.event.participants.length, eventTitle: this.event.title },
),
confirmText: this.$gettextInterpolate(
'Delete %{eventTitle}',
{ eventTitle: this.event.title },
'Delete %{eventTitle}',
{ eventTitle: this.event.title },
),
inputAttrs: {
placeholder: this.event.title,
pattern: this.event.title,
},
onConfirm: () => this.deleteEvent(),
});
}
async reportEvent(content: string, forward: boolean) {
this.isReportModalActive = false;
const eventTitle = this.event.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reporterActorId: this.loggedPerson.id,
reportedActorId: this.event.organizerActor.id,
content,
},
});
this.$buefy.notification.open({
message: this.$gettextInterpolate('Event %{eventTitle} reported', { eventTitle }),
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
async joinEvent() {
try {
await this.$apollo.mutate<{ joinEvent: IParticipant }>({
@@ -408,7 +444,7 @@ export default class Event extends Vue {
await this.$apollo.mutate<IParticipant>({
mutation: DELETE_EVENT,
variables: {
id: this.event.id,
eventId: this.event.id,
actorId: this.loggedPerson.id,
},
});

View File

@@ -0,0 +1,80 @@
import {ReportStatusEnum} from "@/types/report.model";
<template>
<section class="container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
<li class="is-active"><router-link :to="{ name: ModerationRouteName.LOGS }" aria-current="page">Logs</router-link></li>
</ul>
</nav>
<ul v-if="actionLogs.length > 0">
<li v-for="log in actionLogs">
<div class="box">
<img class="image" :src="log.actor.avatar.url" />
<span>@{{ log.actor.preferredUsername }}</span>
<span v-if="log.action === ActionLogAction.REPORT_UPDATE_CLOSED">
closed <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link>
</span>
<span v-else-if="log.action === ActionLogAction.REPORT_UPDATE_OPENED">
reopened <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link>
</span>
<span v-else-if="log.action === ActionLogAction.REPORT_UPDATE_RESOLVED">
marked <router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.id } }">report #{{ log.object.id }}</router-link> as resolved
</span>
<span v-else-if="log.action === ActionLogAction.NOTE_CREATION">
added a note on
<router-link v-if="log.object.report" :to="{ name: ModerationRouteName.REPORT, params: { reportId: log.object.report.id } }">report #{{ log.object.report.id }}</router-link>
<span v-else>a non-existent report</span>
</span>
<span v-else-if="log.action === ActionLogAction.EVENT_DELETION">
deleted an event named « {{ log.object.title }} »
</span>
<br />
<small>{{ log.insertedAt | formatDateTimeString }}</small>
</div>
<!-- <pre>{{ log }}</pre>-->
</li>
</ul>
<div v-else>
<b-message type="is-info">No moderation logs yet</b-message>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { IActionLog, ActionLogAction } from '@/types/report.model';
import { LOGS } from '@/graphql/report';
import ReportCard from '@/components/Report/ReportCard.vue';
import { AdminRouteName } from '@/router/admin';
import { ModerationRouteName } from '@/router/moderation';
@Component({
components: {
ReportCard,
},
apollo: {
actionLogs: {
query: LOGS,
},
},
})
export default class ReportList extends Vue {
actionLogs?: IActionLog[] = [];
ActionLogAction = ActionLogAction;
AdminRouteName = AdminRouteName;
ModerationRouteName = ModerationRouteName;
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<section class="container">
<b-message title="Error" type="is-danger" v-for="error in errors" :key="error">{{ error }}</b-message>
<div class="container" v-if="report">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
<li><router-link :to="{ name: ModerationRouteName.REPORTS }">Reports</router-link></li>
<li class="is-active"><router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">Report</router-link></li>
</ul>
</nav>
<div class="buttons">
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">Mark as resolved</b-button>
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">Reopen</b-button>
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">Close</b-button>
</div>
<div class="columns">
<div class="column">
<div class="table-container">
<table class="box table is-striped">
<tbody>
<tr>
<td>Compte signalé</td>
<td>
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
</router-link>
</td>
</tr>
<tr>
<td>Signalé par</td>
<td>
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
</router-link>
</td>
</tr>
<tr>
<td>Signalé</td>
<td>{{ report.insertedAt | formatDateTimeString }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>Mis à jour</td>
<td>{{ report.updatedAt | formatDateTimeString }}</td>
</tr>
<tr>
<td>Statut</td>
<td>
<span v-if="report.status === ReportStatusEnum.OPEN">Ouvert</span>
<span v-else-if="report.status === ReportStatusEnum.CLOSED">Fermé</span>
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">Résolu</span>
<span v-else>Inconnu</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="column">
<div class="box">
<p v-if="report.content">{{ report.content }}</p>
<p v-else>Pas de commentaire</p>
</div>
</div>
</div>
<div class="box" v-if="report.event">
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: report.event.uuid }}">
<h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description"></p>
</router-link>
<b-button
tag="router-link"
type="is-primary"
:to="{ name: EventRouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">Edit</b-button>
<b-button
type="is-danger"
@click="confirmDelete()"
icon-left="delete"
size="is-small">Delete</b-button>
</div>
<h2 class="title" v-if="report.notes.length > 0">Notes</h2>
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`">
<p>{{ note.content }}</p>
<router-link :to="{ name: ActorRouteName.PROFILE, params: { name: note.moderator.preferredUsername } }">
<img class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
</router-link><br />
<small><a :href="`#note-${note.id}`" v-if="note.insertedAt">{{ note.insertedAt | formatDateTimeString }}</a></small>
</div>
<form @submit="addNote()">
<b-field label="Nouvelle note">
<b-input type="textarea" v-model="noteContent"></b-input>
</b-field>
<b-button type="submit" @click="addNote">Ajouter une note</b-button>
</form>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { CREATE_REPORT_NOTE, REPORT, REPORTS, UPDATE_REPORT } from '@/graphql/report';
import { IReport, IReportNote, ReportStatusEnum } from '@/types/report.model';
import { EventRouteName } from '@/router/event';
import { ActorRouteName } from '@/router/actor';
import { AdminRouteName } from '@/router/admin';
import { ModerationRouteName } from '@/router/moderation';
import { LOGGED_PERSON } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { DELETE_EVENT } from '@/graphql/event';
import { uniq } from 'lodash';
@Component({
apollo: {
report: {
query: REPORT,
variables() {
return {
id: this.reportId,
};
},
error({ graphQLErrors }) {
this.errors = uniq(graphQLErrors.map(({ message }) => message));
},
},
loggedPerson: {
query: LOGGED_PERSON,
},
},
})
export default class Report extends Vue {
@Prop({ required: true }) reportId!: number;
report!: IReport;
loggedPerson!: IPerson;
errors: string[] = [];
ReportStatusEnum = ReportStatusEnum;
EventRouteName = EventRouteName;
ActorRouteName = ActorRouteName;
AdminRouteName = AdminRouteName;
ModerationRouteName = ModerationRouteName;
noteContent: string = '';
addNote() {
try {
this.$apollo.mutate<{ createReportNote: IReportNote }>({
mutation: CREATE_REPORT_NOTE,
variables: {
reportId: this.report.id,
moderatorId: this.loggedPerson.id,
content: this.noteContent,
},
update: (store, { data }) => {
if (data == null) return;
const cachedData = store.readQuery<{ report: IReport }>({ query: REPORT, variables: { id: this.report.id } });
if (cachedData == null) return;
const { report } = cachedData;
if (report === null) {
console.error('Cannot update event notes cache, because of null value.');
return;
}
const note = data.createReportNote;
note.moderator = this.loggedPerson;
report.notes = report.notes.concat([note]);
store.writeQuery({ query: REPORT, data: { report } });
},
});
this.noteContent = '';
} catch (error) {
console.error(error);
}
}
confirmDelete() {
this.$buefy.dialog.confirm({
title: 'Deleting event',
message: 'Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.',
confirmText: 'Delete Event',
type: 'is-danger',
hasIcon: true,
onConfirm: () => this.deleteEvent(),
});
}
async deleteEvent() {
if (!this.report.event || !this.report.event.id) return;
const eventTitle = this.report.event.title;
try {
await this.$apollo.mutate({
mutation: DELETE_EVENT,
variables: {
eventId: this.report.event.id.toString(),
actorId: this.loggedPerson.id,
},
});
this.$buefy.notification.open({
message: this.$gettextInterpolate('Event %{eventTitle} deleted', { eventTitle }),
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
async updateReport(status: ReportStatusEnum) {
try {
await this.$apollo.mutate({
mutation: UPDATE_REPORT,
variables: {
reportId: this.report.id,
moderatorId: this.loggedPerson.id,
status,
},
update: (store, { data }) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({ query: REPORT, variables: { id: this.report.id } });
if (reportCachedData == null) return;
const { report } = reportCachedData;
if (report === null) {
console.error('Cannot update event notes cache, because of null value.');
return;
}
const updatedReport = data.updateReportStatus;
report.status = updatedReport.status;
store.writeQuery({ query: REPORT, data: { report } });
},
});
} catch (error) {
console.error(error);
}
}
// TODO make me a global function
formatDate(value) {
return value ? new Date(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : null;
}
formatTime(value) {
return value ? new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' }) : null;
}
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
tbody td img.image, .note img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
.dialog .modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,90 @@
import {ReportStatusEnum} from "@/types/report.model";
<template>
<section class="container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: AdminRouteName.DASHBOARD }">Dashboard</router-link></li>
<li class="is-active"><router-link :to="{ name: ModerationRouteName.REPORTS }" aria-current="page">Reports</router-link></li>
</ul>
</nav>
<b-field>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.OPEN">
Ouvert
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.RESOLVED">
Résolus
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.CLOSED">
Fermés
</b-radio-button>
</b-field>
<ul v-if="reports.length > 0">
<li v-for="report in reports">
<router-link :to="{ name: ModerationRouteName.REPORT, params: { reportId: report.id } }">
<report-card :report="report" />
</router-link>
</li>
</ul>
<div v-else>
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">No open reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">No resolved reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">No closed reports yet</b-message>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { IReport, ReportStatusEnum } from '@/types/report.model';
import { REPORTS } from '@/graphql/report';
import ReportCard from '@/components/Report/ReportCard.vue';
import { AdminRouteName } from '@/router/admin';
import { ModerationRouteName } from '@/router/moderation';
@Component({
components: {
ReportCard,
},
apollo: {
reports: {
query: REPORTS,
fetchPolicy: 'no-cache',
variables() {
return {
status: this.filterReports,
};
},
pollInterval: 120000, // 2 minutes
},
},
})
export default class ReportList extends Vue {
reports?: IReport[] = [];
AdminRouteName = AdminRouteName;
ModerationRouteName = ModerationRouteName;
ReportStatusEnum = ReportStatusEnum;
filterReports: ReportStatusEnum = ReportStatusEnum.OPEN;
@Watch('$route.params.filter', { immediate: true })
onRouteFilterChanged (val: string) {
if (!val) return;
const filter = val.toUpperCase();
if (filter in ReportStatusEnum) {
this.filterReports = filter as ReportStatusEnum;
}
}
@Watch('filterReports', { immediate: true })
async onFilterChanged (val: string) {
await this.$router.push({ name: ModerationRouteName.REPORTS, params: { filter: val.toLowerCase() } });
}
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
</style>

View File

@@ -143,6 +143,7 @@ export default class Login extends Vue {
id: data.login.user.id,
email: this.credentials.email,
isLoggedIn: true,
role: data.login.user.role,
},
});