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

Close #156 and #158

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
master
Thomas Citharel 3 years ago
parent 164429964a
commit 27f2597b07
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
  1. 9
      js/src/App.vue
  2. 5
      js/src/apollo/user.ts
  3. 2
      js/src/components/Event/EventCard.vue
  4. 11
      js/src/components/Event/EventFullDate.vue
  5. 13
      js/src/components/NavBar.vue
  6. 45
      js/src/components/Report/ReportCard.vue
  7. 101
      js/src/components/Report/ReportModal.vue
  8. 1
      js/src/constants.ts
  9. 19
      js/src/filters/datetime.ts
  10. 9
      js/src/filters/index.ts
  11. 2
      js/src/graphql/actor.ts
  12. 19
      js/src/graphql/admin.ts
  13. 3
      js/src/graphql/auth.ts
  14. 22
      js/src/graphql/event.ts
  15. 2
      js/src/graphql/feed_tokens.ts
  16. 161
      js/src/graphql/report.ts
  17. 5
      js/src/graphql/user.ts
  18. 2
      js/src/main.ts
  19. 16
      js/src/router/admin.ts
  20. 6
      js/src/router/index.ts
  21. 34
      js/src/router/moderation.ts
  22. 9
      js/src/types/admin.model.ts
  23. 7
      js/src/types/current-user.model.ts
  24. 47
      js/src/types/report.model.ts
  25. 7
      js/src/utils/auth.ts
  26. 73
      js/src/views/Admin/Dashboard.vue
  27. 58
      js/src/views/Event/Event.vue
  28. 80
      js/src/views/Moderation/Logs.vue
  29. 271
      js/src/views/Moderation/Report.vue
  30. 90
      js/src/views/Moderation/ReportList.vue
  31. 1
      js/src/views/User/Login.vue
  32. 3
      lib/mobilizon/admin.ex
  33. 12
      lib/mobilizon/admin/action_log.ex
  34. 15
      lib/mobilizon/application.ex
  35. 1
      lib/mobilizon/events/event_options.ex
  36. 16
      lib/mobilizon/reports.ex
  37. 1
      lib/mobilizon/reports/note.ex
  38. 4
      lib/mobilizon/reports/report.ex
  39. 9
      lib/mobilizon_web/api/events.ex
  40. 16
      lib/mobilizon_web/api/reports.ex
  41. 4
      lib/mobilizon_web/api/utils.ex
  42. 10
      lib/mobilizon_web/controllers/node_info_controller.ex
  43. 87
      lib/mobilizon_web/resolvers/admin.ex
  44. 36
      lib/mobilizon_web/resolvers/event.ex
  45. 12
      lib/mobilizon_web/resolvers/group.ex
  46. 12
      lib/mobilizon_web/resolvers/report.ex
  47. 3
      lib/mobilizon_web/router.ex
  48. 7
      lib/mobilizon_web/schema.ex
  49. 2
      lib/mobilizon_web/schema/actor.ex
  50. 10
      lib/mobilizon_web/schema/actors/group.ex
  51. 8
      lib/mobilizon_web/schema/actors/member.ex
  52. 2
      lib/mobilizon_web/schema/actors/person.ex
  53. 4
      lib/mobilizon_web/schema/address.ex
  54. 29
      lib/mobilizon_web/schema/admin.ex
  55. 7
      lib/mobilizon_web/schema/event.ex
  56. 2
      lib/mobilizon_web/schema/events/feed_token.ex
  57. 8
      lib/mobilizon_web/schema/events/participant.ex
  58. 21
      lib/mobilizon_web/schema/report.ex
  59. 8
      lib/mobilizon_web/schema/user.ex
  60. 8
      lib/mobilizon_web/templates/email/report.html.eex
  61. 10
      lib/mobilizon_web/templates/email/report.text.eex
  62. 9
      lib/service/activity_pub/activity_pub.ex
  63. 4
      lib/service/activity_pub/utils.ex
  64. 12
      lib/service/admin/action_log_service.ex
  65. 31
      lib/service/statistics.ex
  66. 94
      schema.graphql
  67. 21
      test/mobilizon/service/activity_pub/activity_pub_test.exs
  68. 4
      test/mobilizon/service/admin/action_log_service_test.exs
  69. 10
      test/mobilizon_web/api/report_test.exs
  70. 5
      test/mobilizon_web/controllers/nodeinfo_controller_test.exs
  71. 54
      test/mobilizon_web/resolvers/admin_resolver_test.exs
  72. 68
      test/mobilizon_web/resolvers/event_resolver_test.exs
  73. 11
      test/mobilizon_web/resolvers/feed_token_resolver_test.exs
  74. 2
      test/mobilizon_web/resolvers/group_resolver_test.exs
  75. 8
      test/mobilizon_web/resolvers/member_resolver_test.exs
  76. 12
      test/mobilizon_web/resolvers/participant_resolver_test.exs
  77. 33
      test/mobilizon_web/resolvers/report_resolver_test.exs

@ -11,7 +11,7 @@
<script lang="ts">
import NavBar from '@/components/NavBar.vue';
import { Component, Vue } from 'vue-property-decorator';
import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants';
import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from '@/constants';
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { ICurrentUser } from '@/types/current-user.model';
import Footer from '@/components/Footer.vue';
@ -46,14 +46,16 @@ export default class App extends Vue {
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken) {
if (userId && userEmail && accessToken && role) {
return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
email: userEmail,
isLoggedIn: true,
role,
},
});
}
@ -74,6 +76,7 @@ export default class App extends Vue {
@import "~bulma/sass/components/navbar.sass";
@import "~bulma/sass/components/pagination.sass";
@import "~bulma/sass/components/dropdown.sass";
@import "~bulma/sass/components/breadcrumb.sass";
@import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass";
@ -84,6 +87,7 @@ export default class App extends Vue {
@import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/notification";
@import "~bulma/sass/elements/table";
@import "~bulma/sass/grid/_all.sass";
@import "~bulma/sass/layout/_all.sass";
@ -100,6 +104,7 @@ export default class App extends Vue {
@import "~buefy/src/scss/components/upload";
@import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch";
@import "~buefy/src/scss/components/table";
.router-enter-active,
.router-leave-active {

@ -1,5 +1,6 @@
import { ApolloCache } from 'apollo-cache';
import { NormalizedCacheObject } from 'apollo-cache-inmemory';
import { ICurrentUserRole } from '@/types/current-user.model';
export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) {
cache.writeData({
@ -9,18 +10,20 @@ export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObjec
id: null,
email: null,
isLoggedIn: false,
role: ICurrentUserRole.USER,
},
},
});
return {
Mutation: {
updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => {
updateCurrentUser: (_, { id, email, isLoggedIn, role }, { cache }) => {
const data = {
currentUser: {
id,
email,
isLoggedIn,
role,
__typename: 'CurrentUser',
},
};

@ -5,7 +5,7 @@
<div class="tag-container" v-if="event.tags">
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-secondary">{{ tag.title }}</b-tag>
</div>
<img src="https://picsum.photos/g/400/225/?random">
<img src="https://picsum.photos/g/400/225/?random" />
</figure>
</div>
<div class="content">

@ -1,8 +1,5 @@
<template>
<translate
v-if="!endsOn"
:translate-params="{date: formatDate(beginsOn), time: formatTime(beginsOn)}"
>The %{ date } at %{ time }</translate>
<span v-if="!endsOn">{{ beginsOn | formatDateTimeString }}</span>
<translate
v-else-if="isSameDay()"
:translate-params="{date: formatDate(beginsOn), startTime: formatTime(beginsOn), endTime: formatTime(endsOn)}"
@ -21,11 +18,13 @@ export default class EventFullDate extends Vue {
@Prop({ required: false }) endsOn!: string;
formatDate(value) {
return value ? new Date(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : null;
if (!this.$options.filters) return;
return this.$options.filters.formatDateString(value);
}
formatTime(value) {
return value ? new Date(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' }) : null;
if (!this.$options.filters) return;
return this.$options.filters.formatTimeString(value);
}
isSameDay() {

@ -44,6 +44,10 @@
<router-link :to="{ name: ActorRouteName.CREATE_GROUP }" v-translate>Create group</router-link>
</span>
<span class="navbar-item" v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR">
<router-link :to="{ name: AdminRouteName.DASHBOARD }" v-translate>Administration</router-link>
</span>
<a v-translate class="navbar-item" v-on:click="logout()">Log out</a>
</div>
</div>
@ -71,10 +75,12 @@ import { LOGGED_PERSON } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import { ICurrentUser } from '@/types/current-user.model';
import { ICurrentUser, ICurrentUserRole } from '@/types/current-user.model';
import Logo from '@/components/Logo.vue';
import SearchField from '@/components/SearchField.vue';
import { ActorRouteName } from '@/router/actor';
import { AdminRouteName } from '@/router/admin';
import { RouteName } from '@/router';
@Component({
apollo: {
@ -98,9 +104,11 @@ export default class NavBar extends Vue {
loggedPerson: IPerson | null = null;
config!: IConfig;
currentUser!: ICurrentUser;
ICurrentUserRole = ICurrentUserRole;
showNavbar: boolean = false;
ActorRouteName = ActorRouteName;
AdminRouteName = AdminRouteName;
@Watch('currentUser')
async onCurrentUserChanged() {
@ -119,7 +127,8 @@ export default class NavBar extends Vue {
async logout() {
await logout(this.$apollo.provider.defaultClient);
return this.$router.push({ path: '/' });
if (this.$route.name === RouteName.HOME) return;
return this.$router.push({ name: RouteName.HOME });
}
}
</script>

@ -0,0 +1,45 @@
<template>
<div class="card" v-if="report">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="report.reported.avatar">
<img :src="report.reported.avatar.url" />
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{ report.reported.name }}</p>
<p class="subtitle is-6">@{{ report.reported.preferredUsername }}</p>
</div>
</div>
<div class="content columns">
<div class="column is-one-quarter box">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
<div class="column box" v-if="report.event">
<img class="image" v-if="report.event.picture" :src="report.event.picture.url" />
<span>{{ report.event.title }}</span>
</div>
<div class="column box" v-if="report.reportContent">{{ report.reportContent }}</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model';
import { EventRouteName } from '@/router/event';
@Component
export default class ReportCard extends Vue {
@Prop({ required: true }) report!: IReport;
EventRouteName = EventRouteName;
}
</script>
<style lang="scss">
.content img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>

@ -0,0 +1,101 @@
<template>
<div class="modal-card">
<header class="modal-card-head" v-if="title">
<p class="modal-card-title">{{ title }}</p>
</header>
<section
class="modal-card-body is-flex"
:class="{ 'is-titleless': !title }">
<div class="media">
<div
class="media-left">
<b-icon
icon="alert"
type="is-warning"
size="is-large"/>
</div>
<div class="media-content">
<p>The report will be sent to the moderators of your instance.
You can explain why you report this content below.</p>
<div class="control">
<b-input
v-model="content"
type="textarea"
@keyup.enter="confirm"
placeholder="Additional comments"
/>
</div>
<p v-if="outsideDomain">
The content came from another server. Transfer an anonymous copy of the report ?
</p>
<div class="control" v-if="outsideDomain">
<b-switch v-model="forward">Transfer to {{ outsideDomain }}</b-switch>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="cancel('button')">
{{ cancelText }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="confirm">
{{ confirmText }}
</button>
</footer>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { removeElement } from 'buefy/src/utils/helpers';
@Component({
mounted() {
this.$data.isActive = true;
},
})
export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: String }) title;
@Prop({ type: String, default: '' }) outsideDomain;
@Prop({ type: String, default: 'Cancel' }) cancelText;
@Prop({ type: String, default: 'Send the report' }) confirmText;
isActive: boolean = false;
content: string = '';
forward: boolean = false;
confirm() {
this.onConfirm(this.content, this.forward);
this.close();
}
/**
* Close the Dialog.
*/
close() {
this.isActive = false;
// Timeout for the animation complete before destroying
setTimeout(() => {
this.$destroy();
removeElement(this.$el);
}, 150);
}
}
</script>
<style lang="scss">
.modal-card .modal-card-foot {
justify-content: flex-end;
}
</style>

@ -3,3 +3,4 @@ export const AUTH_REFRESH_TOKEN = 'auth-refresh-token';
export const AUTH_USER_ID = 'auth-user-id';
export const AUTH_USER_EMAIL = 'auth-user-email';
export const AUTH_USER_ACTOR = 'auth-user-actor';
export const AUTH_USER_ROLE = 'auth-user-role';

@ -0,0 +1,19 @@
function parseDateTime(value: string): Date {
return new Date(value);
}
function formatDateString(value: string): string {
return parseDateTime(value).toLocaleString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function formatTimeString(value: string): string {
return parseDateTime(value).toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric' });
}
function formatDateTimeString(value: string): string {
return parseDateTime(value).toLocaleTimeString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
}
export { formatDateString, formatTimeString, formatDateTimeString };

@ -0,0 +1,9 @@
import { formatDateString, formatTimeString, formatDateTimeString } from './datetime';
export default {
install(vue) {
vue.filter('formatDateString', formatDateString);
vue.filter('formatTimeString', formatTimeString);
vue.filter('formatDateTimeString', formatDateTimeString);
},
};

@ -177,7 +177,7 @@ query($name:String!) {
export const CREATE_GROUP = gql`
mutation CreateGroup(
$creatorActorId: Int!,
$creatorActorId: ID!,
$preferredUsername: String!,
$name: String!,
$summary: String,

@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const DASHBOARD = gql`
query {
dashboard {
lastPublicEventPublished {
title,
picture {
alt
url
},
},
numberOfUsers,
numberOfEvents,
numberOfComments,
numberOfReports
}
}
`;

@ -7,7 +7,8 @@ mutation Login($email: String!, $password: String!) {
refreshToken,
user {
id,
email
email,
role
}
},
}

@ -70,8 +70,8 @@ export const FETCH_EVENT = gql`
},
publishAt,
category,
online_address,
phone_address,
onlineAddress,
phoneAddress,
physicalAddress {
${physicalAddressQuery}
}
@ -218,8 +218,8 @@ export const CREATE_EVENT = gql`
},
publishAt,
category,
online_address,
phone_address,
onlineAddress,
phoneAddress,
physicalAddress {
${physicalAddressQuery}
},
@ -240,7 +240,7 @@ export const EDIT_EVENT = gql`
$description: String,
$beginsOn: DateTime,
$endsOn: DateTime,
$status: Int,
$status: EventStatus,
$visibility: EventVisibility
$tags: [String],
$picture: PictureInput,
@ -280,8 +280,8 @@ export const EDIT_EVENT = gql`
},
publishAt,
category,
online_address,
phone_address,
onlineAddress,
phoneAddress,
physicalAddress {
${physicalAddressQuery}
},
@ -296,7 +296,7 @@ export const EDIT_EVENT = gql`
`;
export const JOIN_EVENT = gql`
mutation JoinEvent($eventId: Int!, $actorId: Int!) {
mutation JoinEvent($eventId: ID!, $actorId: ID!) {
joinEvent(
eventId: $eventId,
actorId: $actorId
@ -307,7 +307,7 @@ export const JOIN_EVENT = gql`
`;
export const LEAVE_EVENT = gql`
mutation LeaveEvent($eventId: Int!, $actorId: Int!) {
mutation LeaveEvent($eventId: ID!, $actorId: ID!) {
leaveEvent(
eventId: $eventId,
actorId: $actorId
@ -320,9 +320,9 @@ export const LEAVE_EVENT = gql`
`;
export const DELETE_EVENT = gql`
mutation DeleteEvent($id: Int!, $actorId: Int!) {
mutation DeleteEvent($eventId: ID!, $actorId: ID!) {
deleteEvent(
eventId: $id,
eventId: $eventId,
actorId: $actorId
) {
id

@ -12,7 +12,7 @@ query {
}`;
export const CREATE_FEED_TOKEN_ACTOR = gql`
mutation createFeedToken($actor_id: Int!) {
mutation createFeedToken($actor_id: ID!) {
createFeedToken(actorId: $actor_id) {
token,
actor {

@ -0,0 +1,161 @@
import gql from 'graphql-tag';
export const REPORTS = gql`
query Reports($status: ReportStatus) {
reports(status: $status) {
id,
reported {
id,
preferredUsername,
name,
avatar {
url
}
},
reporter {
id,
preferredUsername,
name,
avatar {
url
}
},
event {
id,
uuid,
title,
picture {
url
}
},
status
}
}
`;
const REPORT_FRAGMENT = gql`
fragment ReportFragment on Report {
id,
reported {
id,
preferredUsername,
name,
avatar {
url
}
},
reporter {
id,
preferredUsername,
name,
avatar {
url
}
},
event {
id,
uuid,
title,
description,
picture {
url
}
},
notes {
id,
content
moderator {
preferredUsername,
name,
avatar {
url
}
},
insertedAt
},
insertedAt,
updatedAt,
status,
content
}
`;
export const REPORT = gql`
query Report($id: ID!) {
report(id: $id) {
...ReportFragment
}
}
${REPORT_FRAGMENT}
`;
export const CREATE_REPORT = gql`
mutation CreateReport(
$eventId: ID!,
$reporterActorId: ID!,
$reportedActorId: ID!,
$content: String
) {
createReport(eventId: $eventId, reporterActorId: $reporterActorId, reportedActorId: $reportedActorId, content: $content) {
id
}
}
`;
export const UPDATE_REPORT = gql`
mutation UpdateReport(
$reportId: ID!,
$moderatorId: ID!,
$status: ReportStatus!
) {
updateReportStatus(reportId: $reportId, moderatorId: $moderatorId, status: $status) {
...ReportFragment
}
}
${REPORT_FRAGMENT}
`;
export const CREATE_REPORT_NOTE = gql`
mutation CreateReportNote(
$reportId: ID!,
$moderatorId: ID!,
$content: String!
) {
createReportNote(reportId: $reportId, moderatorId: $moderatorId, content: $content) {
id,
content,
insertedAt
}
}
`;
export const LOGS = gql`
query {
actionLogs {
id,
action,
actor {
id,
preferredUsername
avatar {
url
}
},
object {
...on Report {
id
},
... on ReportNote {
report {
id,
}
}
... on Event {
id,
title
}
},
insertedAt
}
}
`;

@ -31,12 +31,13 @@ query {
id,
email,
isLoggedIn,
role
}
}
`;
export const UPDATE_CURRENT_USER_CLIENT = gql`
mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!) {
updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn) @client
mutation UpdateCurrentUser($id: Int!, $email: String!, $isLoggedIn: Boolean!, $role: UserRole!) {
updateCurrentUser(id: $id, email: $email, isLoggedIn: $isLoggedIn, role: $role) @client
}
`;

@ -7,6 +7,7 @@ import App from '@/App.vue';
import router from '@/router';
import { apolloProvider } from './vue-apollo';
import { NotifierPlugin } from '@/plugins/notifier';
import filters from '@/filters';
const translations = require('@/i18n/translations.json');
@ -14,6 +15,7 @@ Vue.config.productionTip = false;
Vue.use(Buefy);
Vue.use(NotifierPlugin);
Vue.use(filters);
const language = (window.navigator as any).userLanguage || window.navigator.language;

@ -0,0 +1,16 @@
import { RouteConfig } from 'vue-router';
import Dashboard from '@/views/Admin/Dashboard.vue';
export enum AdminRouteName {
DASHBOARD = 'Dashboard',
}
export const adminRoutes: RouteConfig[] = [
{
path: '/admin',
name: AdminRouteName.DASHBOARD,
component: Dashboard,
props: true,
meta: { requiredAuth: true },
},
];

@ -5,9 +5,11 @@ import Home from '@/views/Home.vue';
import { UserRouteName, userRoutes } from './user';
import { EventRouteName, eventRoutes } from '@/router/event';
import { ActorRouteName, actorRoutes, MyAccountRouteName } from '@/router/actor';
import { AdminRouteName, adminRoutes } from '@/router/admin';
import { ErrorRouteName, errorRoutes } from '@/router/error';
import { authGuardIfNeeded } from '@/router/guards/auth-guard';
import Search from '@/views/Search.vue';
import { ModerationRouteName, moderationRoutes } from '@/router/moderation';
Vue.use(Router);
@ -35,6 +37,8 @@ export const RouteName = {
...EventRouteName,
...ActorRouteName,
...MyAccountRouteName,
...AdminRouteName,
...ModerationRouteName,
...ErrorRouteName,
};
@ -46,6 +50,8 @@ const router = new Router({
...userRoutes,
...eventRoutes,
...actorRoutes,
...adminRoutes,
...moderationRoutes,
...errorRoutes,
{
path: '/search/:searchTerm/:searchType?',

@ -0,0 +1,34 @@
import { RouteConfig } from 'vue-router';
import ReportList from '@/views/Moderation/ReportList.vue';
import Report from '@/views/Moderation/Report.vue';
import Logs from '@/views/Moderation/Logs.vue';
export enum ModerationRouteName {
REPORTS = 'Reports',
REPORT = 'Report',
LOGS = 'Logs',
}
export const moderationRoutes: RouteConfig[] = [
{
path: '/moderation/reports/:filter?',
name: ModerationRouteName.REPORTS,
component: ReportList,
props: true,
meta: { requiredAuth: true },
},
{
path: '/moderation/report/:reportId',
name: ModerationRouteName.REPORT,
component: Report,
props: true,
meta: { requiredAuth: true },
},
{
path: '/moderation/logs',
name: ModerationRouteName.LOGS,
component: Logs,
props: true,
meta: { requiredAuth: true },
},
];

@ -0,0 +1,9 @@
import { IEvent } from '@/types/event.model';
export interface IDashboard {
lastPublicEventPublished: IEvent;
numberOfUsers: number;
numberOfEvents: number;
numberOfComments: number;
numberOfReports: number;
}

@ -1,5 +1,12 @@
export enum ICurrentUserRole {
USER = 'USER',
MODERATOR = 'MODERATOR',
ADMINISTRATOR = 'ADMINISTRATOR',
}
export interface ICurrentUser {
id: number;
email: string;
isLoggedIn: boolean;
role: ICurrentUserRole;
}

@ -0,0 +1,47 @@
import { IActor, IPerson } from '@/types/actor';
import { IEvent } from '@/types/event.model';
export enum ReportStatusEnum {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
RESOLVED = 'RESOLVED',
}
export interface IReport extends IActionLogObject {
id: string;
reported: IActor;
reporter: IPerson;
event?: IEvent;
content: string;
notes: IReportNote[];
insertedAt: Date;
updatedAt: Date;
status: ReportStatusEnum;
}
export interface IReportNote extends IActionLogObject{
id: string;
content: string;
moderator: IActor;
}
export interface IActionLogObject {
id: string;
}
export enum ActionLogAction {
NOTE_CREATION = 'NOTE_CREATION',
NOTE_DELETION = 'NOTE_DELETION',
REPORT_UPDATE_CLOSED = 'REPORT_UPDATE_CLOSED',
REPORT_UPDATE_OPENED = 'REPORT_UPDATE_OPENED',
REPORT_UPDATE_RESOLVED = 'REPORT_UPDATE_RESOLVED',
EVENT_DELETION = 'EVENT_DELETION',
}
export interface IActionLog {
id: string;
object: IReport|IReportNote|IEvent;
actor: IActor;
action: ActionLogAction;
insertedAt: Date;
}

@ -1,12 +1,14 @@
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants';
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID, AUTH_USER_ROLE } from '@/constants';
import { ILogin, IToken } from '@/types/login.model';
import { UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { onLogout } from '@/vue-apollo';
import ApolloClient from 'apollo-client';
import { ICurrentUserRole } from '@/types/current-user.model';
export function saveUserData(obj: ILogin) {
localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`);
localStorage.setItem(AUTH_USER_EMAIL, obj.user.email);
localStorage.setItem(AUTH_USER_ROLE, obj.user.role);
saveTokenData(obj);
}
@ -17,7 +19,7 @@ export function saveTokenData(obj: IToken) {
}
export function deleteUserData() {
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN]) {
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_ROLE]) {
localStorage.removeItem(key);
}
}
@ -29,6 +31,7 @@ export function logout(apollo: ApolloClient<any>) {
id: null,
email: null,
isLoggedIn: false,
role: ICurrentUserRole.USER,
},
});

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

@ -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,
},
});

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

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