Merge branch 'admin-user-management' into 'master'

Introduce basic user and profile management

See merge request framasoft/mobilizon!463
This commit is contained in:
Thomas Citharel 2020-06-12 19:18:25 +02:00
commit 0d98eefc18
51 changed files with 1808 additions and 254 deletions

View file

@ -32,7 +32,13 @@ export const FETCH_PERSON = gql`
`; `;
export const GET_PERSON = gql` export const GET_PERSON = gql`
query($actorId: ID!) { query(
$actorId: ID!
$organizedEventsPage: Int
$organizedEventsLimit: Int
$participationPage: Int
$participationLimit: Int
) {
person(id: $actorId) { person(id: $actorId) {
id id
url url
@ -51,10 +57,63 @@ export const GET_PERSON = gql`
feedTokens { feedTokens {
token token
} }
organizedEvents { organizedEvents(page: $organizedEventsPage, limit: $organizedEventsLimit) {
uuid total
title elements {
beginsOn id
uuid
title
beginsOn
}
}
participations(page: $participationPage, limit: $participationLimit) {
total
elements {
id
event {
id
uuid
title
beginsOn
}
}
}
user {
id
email
}
}
}
`;
export const LIST_PROFILES = gql`
query ListProfiles(
$preferredUsername: String
$name: String
$domain: String
$local: Boolean
$suspended: Boolean
$page: Int
$limit: Int
) {
persons(
preferredUsername: $preferredUsername
name: $name
domain: $domain
local: $local
suspended: $suspended
page: $page
limit: $limit
) {
total
elements {
id
preferredUsername
domain
name
avatar {
url
}
} }
} }
} }
@ -505,3 +564,19 @@ export const CREATE_GROUP = gql`
} }
} }
`; `;
export const SUSPEND_PROFILE = gql`
mutation SuspendProfile($id: ID!) {
suspendProfile(id: $id) {
id
}
}
`;
export const UNSUSPEND_PROFILE = gql`
mutation UnSuspendProfile($id: ID!) {
unsuspendProfile(id: $id) {
id
}
}
`;

View file

@ -485,13 +485,16 @@ export const EVENT_PERSON_PARTICIPATION = gql`
person(id: $actorId) { person(id: $actorId) {
id id
participations(eventId: $eventId) { participations(eventId: $eventId) {
id total
role elements {
actor {
id
}
event {
id id
role
actor {
id
}
event {
id
}
} }
} }
} }
@ -503,13 +506,16 @@ export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
eventPersonParticipationChanged(personId: $actorId) { eventPersonParticipationChanged(personId: $actorId) {
id id
participations(eventId: $eventId) { participations(eventId: $eventId) {
id total
role elements {
actor {
id
}
event {
id id
role
actor {
id
}
event {
id
}
} }
} }
} }

View file

@ -178,6 +178,12 @@ export const LOGS = gql`
id id
title title
} }
... on Person {
id
preferredUsername
domain
name
}
} }
insertedAt insertedAt
} }

View file

@ -64,8 +64,8 @@ export const VALIDATE_EMAIL = gql`
`; `;
export const DELETE_ACCOUNT = gql` export const DELETE_ACCOUNT = gql`
mutation DeleteAccount($password: String!) { mutation DeleteAccount($password: String, $userId: ID!) {
deleteAccount(password: $password) { deleteAccount(password: $password, userId: $userId) {
id id
} }
} }
@ -134,3 +134,58 @@ export const SET_USER_SETTINGS = gql`
} }
${USER_SETTINGS_FRAGMENT} ${USER_SETTINGS_FRAGMENT}
`; `;
export const LIST_USERS = gql`
query ListUsers($email: String, $page: Int, $limit: Int) {
users(email: $email, page: $page, limit: $limit) {
total
elements {
id
email
locale
confirmedAt
disabled
actors {
id
preferredUsername
avatar {
url
}
name
summary
}
settings {
timezone
}
}
}
}
`;
export const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
email
confirmedAt
confirmationSentAt
locale
disabled
defaultActor {
id
}
actors {
id
preferredUsername
name
avatar {
url
}
}
participations {
total
}
role
}
}
`;

View file

@ -607,5 +607,25 @@
"Your timezone was detected as {timezone}.": "Your timezone was detected as {timezone}.", "Your timezone was detected as {timezone}.": "Your timezone was detected as {timezone}.",
"Manage my settings": "Manage my settings", "Manage my settings": "Manage my settings",
"Let's define a few settings": "Let's define a few settings", "Let's define a few settings": "Let's define a few settings",
"All good, let's continue!": "All good, let's continue!" "All good, let's continue!": "All good, let's continue!",
"Organize and take action, freely": "Organize and take action, freely",
"Let\\'s create a new common": "Let\\'s create a new common",
"Login status": "Login status",
"Suspended": "Suspended",
"Active": "Active",
"Local": "Local",
"User": "User",
"Confirmed": "Confirmed",
"Confirmed at": "Confirmed at",
"Language": "Language",
"Administrator": "Administrator",
"Moderator": "Moderator",
"{number} organized events": "No organized events|One organized event|{number} organized events",
"Begins on": "Begins on",
"{number} participations": "No participations|One participation|{number} participations",
"{profile} (by default)": "{profile} (by default)",
"Participations": "Participations",
"Nothing to see here": "Nothing to see here",
"Not confirmed": "Not confirmed",
"{actor} suspended profile {profile}": "{actor} suspended profile {profile}"
} }

View file

@ -626,5 +626,26 @@
"Your timezone was detected as {timezone}.": "Votre fuseau horaire a été détecté en tant que {timezone}.", "Your timezone was detected as {timezone}.": "Votre fuseau horaire a été détecté en tant que {timezone}.",
"Manage my settings": "Gérer mes paramètres", "Manage my settings": "Gérer mes paramètres",
"Let's define a few settings": "Définissons quelques paramètres", "Let's define a few settings": "Définissons quelques paramètres",
"All good, let's continue!": "C'est tout bon, continuons !" "All good, let's continue!": "C'est tout bon, continuons !",
"Organize and take action, freely": "S'organiser et agir, librement",
"Let\\'s create a new common": "Créeons un nouveau common",
"Login status": "Statut de connexion",
"Suspended": "Suspendu·e",
"Active": "Actif·ve",
"Local": "Local·e",
"User": "Utilisateur·ice",
"{profile} (by default)": "{profile} (par défault)",
"Locale": "Locale",
"Confirmed": "Confirmé·e",
"Confirmed at": "Confirmé·e à",
"Language": "Langue",
"Administrator": "Administrateur·ice",
"Moderator": "Moderateur·ice",
"{number} organized events": "Aucun événement organisé|Un événement organisé|{number} événements organisés",
"{number} participations": "Aucune participation|Une participation|{number} participations",
"Begins on": "Commence le",
"Participations": "Participations",
"Nothing to see here": "Il n'y a rien à voir ici",
"Not confirmed": "Non confirmé·e",
"{actor} suspended profile {profile}": "{actor} a suspendu le profil {profile}"
} }

View file

@ -12,6 +12,10 @@ import ReportList from "@/views/Moderation/ReportList.vue";
import Report from "@/views/Moderation/Report.vue"; import Report from "@/views/Moderation/Report.vue";
import Logs from "@/views/Moderation/Logs.vue"; import Logs from "@/views/Moderation/Logs.vue";
import EditIdentity from "@/views/Account/children/EditIdentity.vue"; import EditIdentity from "@/views/Account/children/EditIdentity.vue";
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";
export enum SettingsRouteName { export enum SettingsRouteName {
SETTINGS = "SETTINGS", SETTINGS = "SETTINGS",
@ -25,6 +29,10 @@ export enum SettingsRouteName {
RELAYS = "Relays", RELAYS = "Relays",
RELAY_FOLLOWINGS = "Followings", RELAY_FOLLOWINGS = "Followings",
RELAY_FOLLOWERS = "Followers", RELAY_FOLLOWERS = "Followers",
USERS = "USERS",
PROFILES = "PROFILES",
ADMIN_PROFILE = "ADMIN_PROFILE",
ADMIN_USER_PROFILE = "ADMIN_USER_PROFILE",
MODERATION = "MODERATION", MODERATION = "MODERATION",
REPORTS = "Reports", REPORTS = "Reports",
REPORT = "Report", REPORT = "Report",
@ -87,6 +95,34 @@ export const settingsRoutes: RouteConfig[] = [
props: true, props: true,
meta: { requiredAuth: true }, meta: { requiredAuth: true },
}, },
{
path: "admin/users",
name: SettingsRouteName.USERS,
component: Users,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/users/:id",
name: SettingsRouteName.ADMIN_USER_PROFILE,
component: AdminUserProfile,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/profiles",
name: SettingsRouteName.PROFILES,
component: Profiles,
props: true,
meta: { requiredAuth: true },
},
{
path: "admin/profiles/:id",
name: SettingsRouteName.ADMIN_PROFILE,
component: AdminProfile,
props: true,
meta: { requiredAuth: true },
},
{ {
path: "admin/relays", path: "admin/relays",
name: SettingsRouteName.RELAYS, name: SettingsRouteName.RELAYS,

View file

@ -66,3 +66,10 @@ export function usernameWithDomain(actor: IActor): string {
} }
return actor.preferredUsername; return actor.preferredUsername;
} }
export function displayNameAndUsername(actor: IActor): string {
if (actor.name) {
return `${actor.name} (@${usernameWithDomain(actor)})`;
}
return usernameWithDomain(actor);
}

View file

@ -15,6 +15,7 @@ export interface IPerson extends IActor {
goingToEvents: IEvent[]; goingToEvents: IEvent[];
participations: IParticipant[]; participations: IParticipant[];
memberships: Paginate<IMember>; memberships: Paginate<IMember>;
user?: ICurrentUser;
} }
export class Person extends Actor implements IPerson { export class Person extends Actor implements IPerson {
@ -26,6 +27,8 @@ export class Person extends Actor implements IPerson {
memberships!: Paginate<IMember>; memberships!: Paginate<IMember>;
user!: ICurrentUser;
constructor(hash: IPerson | {} = {}) { constructor(hash: IPerson | {} = {}) {
super(hash); super(hash);

View file

@ -19,6 +19,14 @@ export interface ICurrentUser {
settings: IUserSettings; settings: IUserSettings;
} }
export interface IUser extends ICurrentUser {
confirmedAt: Date;
confirmationSendAt: Date;
locale: String;
actors: IPerson[];
disabled: boolean;
}
export enum INotificationPendingParticipationEnum { export enum INotificationPendingParticipationEnum {
NONE = "NONE", NONE = "NONE",
DIRECT = "DIRECT", DIRECT = "DIRECT",

View file

@ -39,11 +39,13 @@ export enum ActionLogAction {
REPORT_UPDATE_RESOLVED = "REPORT_UPDATE_RESOLVED", REPORT_UPDATE_RESOLVED = "REPORT_UPDATE_RESOLVED",
EVENT_DELETION = "EVENT_DELETION", EVENT_DELETION = "EVENT_DELETION",
COMMENT_DELETION = "COMMENT_DELETION", COMMENT_DELETION = "COMMENT_DELETION",
ACTOR_SUSPENSION = "ACTOR_SUSPENSION",
ACTOR_UNSUSPENSION = "ACTOR_UNSUSPENSION",
} }
export interface IActionLog { export interface IActionLog {
id: string; id: string;
object: IReport | IReportNote | IEvent; object: IReport | IReportNote | IEvent | IComment | IActor;
actor: IActor; actor: IActor;
action: ActionLogAction; action: ActionLogAction;
insertedAt: Date; insertedAt: Date;

View file

@ -0,0 +1,318 @@
<template>
<div v-if="person" 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.PROFILES,
}"
>{{ $t("Profiles") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.PROFILES,
params: { id: person.id },
}"
>{{ person.name || person.preferredUsername }}</router-link
>
</li>
</ul>
</nav>
<article class="media">
<figure class="media-left" v-if="person.avatar">
<p class="image is-48x48">
<img :src="person.avatar.url" alt="" />
</p>
</figure>
<div class="media-content">
<div class="content">
<strong v-if="person.name">{{ person.name }}</strong>
<small>@{{ usernameWithDomain(person) }}</small>
<p v-html="person.summary" />
</div>
</div>
</article>
<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="suspendProfile"
v-if="person.domain && !person.suspended"
type="is-primary"
>{{ $t("Suspend") }}</b-button
>
<b-button
@click="unsuspendProfile"
v-if="person.domain && person.suspended"
type="is-primary"
>{{ $t("Unsuspend") }}</b-button
>
</div>
<section>
<h2 class="subtitle">
{{
$tc("{number} organized events", person.organizedEvents.total, {
number: person.organizedEvents.total,
})
}}
</h2>
<b-table
:data="person.organizedEvents.elements"
:loading="$apollo.queries.person.loading"
paginated
backend-pagination
:total="person.organizedEvents.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onOrganizedEventsPageChange"
>
<template slot-scope="props">
<b-table-column field="beginsOn" :label="$t('Begins on')">
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<b-table-column field="title" :label="$t('Title')">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }">
{{ props.row.title }}
</router-link>
</b-table-column>
</template>
<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} participations", person.participations.total, {
number: person.participations.total,
})
}}
</h2>
<b-table
:data="person.participations.elements.map((participation) => participation.event)"
:loading="$apollo.queries.person.loading"
paginated
backend-pagination
:total="person.participations.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onParticipationsPageChange"
>
<template slot-scope="props">
<b-table-column field="beginsOn" :label="$t('Begins on')">
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<b-table-column field="title" :label="$t('Title')">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }">
{{ props.row.title }}
</router-link>
</b-table-column>
</template>
<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 { GET_PERSON, SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IEvent } from "../../types/event.model";
const EVENTS_PER_PAGE = 10;
@Component({
apollo: {
person: {
query: GET_PERSON,
variables() {
return {
actorId: this.id,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
};
},
skip() {
return !this.id;
},
},
},
})
export default class AdminProfile extends Vue {
@Prop({ required: true }) id!: String;
person!: IPerson;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
organizedEventsPage = 1;
participationsPage = 1;
get metadata(): Array<object> {
if (!this.person) return [];
const res: object[] = [
{
key: this.$t("Status") as string,
value: this.person.suspended ? this.$t("Suspended") : this.$t("Active"),
},
{
key: this.$t("Domain") as string,
value: this.person.domain ? this.person.domain : this.$t("Local"),
},
];
if (!this.person.domain && this.person.user) {
res.push({
key: this.$t("User") as string,
link: { name: RouteName.ADMIN_USER_PROFILE, params: { id: this.person.user.id } },
value: this.person.user.email,
});
}
return res;
}
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<{ person: IPerson }>({
query: GET_PERSON,
variables: {
actorId: profileId,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
},
});
if (!profileData) return;
const { person } = profileData;
person.suspended = true;
person.avatar = null;
person.name = "";
person.summary = "";
store.writeQuery({
query: GET_PERSON,
variables: {
actorId: profileId,
},
data: { person },
});
},
});
}
async unsuspendProfile() {
const profileID = this.id;
this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE,
variables: {
id: this.id,
},
refetchQueries: [
{
query: GET_PERSON,
variables: {
actorId: profileID,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
},
},
],
});
}
async onOrganizedEventsPageChange(page: number) {
this.organizedEventsPage = page;
await this.$apollo.queries.person.fetchMore({
variables: {
actorId: this.id,
organizedEventsPage: this.organizedEventsPage,
organizedEventsLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newOrganizedEvents = fetchMoreResult.person.organizedEvents.elements;
return {
person: {
...previousResult.person,
organizedEvents: {
__typename: previousResult.person.organizedEvents.__typename,
total: previousResult.person.organizedEvents.total,
elements: [...previousResult.person.organizedEvents.elements, ...newOrganizedEvents],
},
},
};
},
});
}
async onParticipationsPageChange(page: number) {
this.participationsPage = page;
await this.$apollo.queries.person.fetchMore({
variables: {
actorId: this.id,
participationPage: this.participationsPage,
participationLimit: EVENTS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newParticipations = fetchMoreResult.person.participations.elements;
return {
person: {
...previousResult.person,
participations: {
__typename: previousResult.person.participations.__typename,
total: previousResult.person.participations.total,
elements: [...previousResult.person.participations.elements, ...newParticipations],
},
},
};
},
});
}
}
</script>
<style lang="scss" scoped>
table,
section {
margin: 2rem 0;
}
</style>

View file

@ -0,0 +1,165 @@
<template>
<div v-if="user" 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.USERS,
}"
>{{ $t("Users") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: user.id },
}"
>{{ user.email }}</router-link
>
</li>
</ul>
</nav>
<table v-if="metadata.length > 0" class="table is-fullwidth">
<tbody>
<tr v-for="{ key, value, link, elements } in metadata" :key="key">
<td>{{ key }}</td>
<td v-if="elements && elements.length > 0">
<ul v-for="{ value, link: elementLink, active } in elements" :key="value">
<li>
<router-link :to="elementLink">
<span v-if="active">{{ $t("{profile} (by default)", { profile: value }) }}</span>
<span v-else>{{ value }}</span>
</router-link>
</li>
</ul>
</td>
<td v-else-if="elements">
{{ $t("None") }}
</td>
<td v-else-if="link">
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td v-else>{{ value }}</td>
</tr>
</tbody>
</table>
<div class="buttons">
<b-button @click="deleteAccount" v-if="!user.disabled" type="is-primary">{{
$t("Suspend")
}}</b-button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { GET_USER, DELETE_ACCOUNT } from "../../graphql/user";
import { usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IUser, ICurrentUserRole } from "../../types/current-user.model";
import { IPerson } from "../../types/actor";
@Component({
apollo: {
user: {
query: GET_USER,
variables() {
return {
id: this.id,
};
},
skip() {
return !this.id;
},
},
},
})
export default class AdminUserProfile extends Vue {
@Prop({ required: true }) id!: String;
user!: IUser;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
get metadata(): Array<object> {
if (!this.user) return [];
return [
{
key: this.$i18n.t("Email"),
value: this.user.email,
},
{
key: this.$i18n.t("Language"),
value: this.user.locale,
},
{
key: this.$i18n.t("Role"),
value: this.roleName(this.user.role),
},
{
key: this.$i18n.t("Login status"),
value: this.user.disabled ? this.$i18n.t("Disabled") : this.$t("Activated"),
},
{
key: this.$i18n.t("Profiles"),
elements: this.user.actors.map((actor: IPerson) => {
return {
link: { name: RouteName.ADMIN_PROFILE, params: { id: actor.id } },
value: actor.name
? `${actor.name} (${actor.preferredUsername})`
: actor.preferredUsername,
active: this.user.defaultActor ? actor.id === this.user.defaultActor.id : false,
};
}),
},
{
key: this.$i18n.t("Confirmed"),
value:
this.$options.filters && this.user.confirmedAt
? this.$options.filters.formatDateTimeString(this.user.confirmedAt)
: this.$i18n.t("Not confirmed"),
},
{
key: this.$i18n.t("Participations"),
value: this.user.participations.total,
},
];
}
roleName(role: ICurrentUserRole): string {
switch (role) {
case ICurrentUserRole.ADMINISTRATOR:
return this.$t("Administrator") as string;
case ICurrentUserRole.MODERATOR:
return this.$t("Moderator") as string;
case ICurrentUserRole.USER:
default:
return this.$t("User") as string;
}
}
async deleteAccount() {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: DELETE_ACCOUNT,
variables: {
userId: this.id,
},
});
return this.$router.push({ name: RouteName.USERS });
}
}
</script>
<style lang="scss" scoped>
table {
margin: 2rem 0;
}
</style>

View file

@ -16,15 +16,17 @@
</div> </div>
<div class="tile is-parent is-vertical"> <div class="tile is-parent is-vertical">
<article class="tile is-child box"> <article class="tile is-child box">
<p class="dashboard-number">{{ dashboard.numberOfUsers }}</p> <router-link :to="{ name: RouteName.USERS }">
<p>{{ $t("Users") }}</p> <p class="dashboard-number">{{ dashboard.numberOfUsers }}</p>
<p>{{ $t("Users") }}</p>
</router-link>
</article> </article>
<router-link :to="{ name: RouteName.REPORTS }"> <article class="tile is-child box">
<article class="tile is-child box"> <router-link :to="{ name: RouteName.REPORTS }">
<p class="dashboard-number">{{ dashboard.numberOfReports }}</p> <p class="dashboard-number">{{ dashboard.numberOfReports }}</p>
<p>{{ $t("Opened reports") }}</p> <p>{{ $t("Opened reports") }}</p>
</article> </router-link>
</router-link> </article>
</div> </div>
</div> </div>
<div class="tile is-parent" v-if="dashboard.lastPublicEventPublished"> <div class="tile is-parent" v-if="dashboard.lastPublicEventPublished">
@ -80,4 +82,10 @@ export default class Dashboard extends Vue {
font-weight: 700; font-weight: 700;
line-height: 1.125; line-height: 1.125;
} }
article.tile {
a {
color: #4a4a4a;
}
}
</style> </style>

View file

@ -0,0 +1,138 @@
<template>
<div v-if="persons">
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>
<b-table
:data="persons.elements"
:loading="$apollo.queries.persons.loading"
paginated
backend-pagination
backend-filtering
:total="persons.total"
:per-page="PROFILES_PER_PAGE"
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<template slot-scope="props">
<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>
<router-link :to="{ name: RouteName.ADMIN_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>
</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>
{{ props.row.domain }}
</b-table-column>
</template>
<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>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { LIST_PROFILES } from "../../graphql/actor";
import RouteName from "../../router/name";
const PROFILES_PER_PAGE = 10;
@Component({
apollo: {
persons: {
query: LIST_PROFILES,
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 Profiles 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.persons.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.persons.elements;
return {
persons: {
__typename: previousResult.persons.__typename,
total: previousResult.persons.total,
elements: [...previousResult.persons.elements, ...newProfiles],
},
};
},
});
}
onFiltersChange({ preferredUsername, domain }: { preferredUsername: string; domain: string }) {
this.preferredUsername = preferredUsername;
this.domain = domain;
}
@Watch("domain")
domainNotLocal() {
this.local = this.domain === "";
}
}
</script>

View file

@ -0,0 +1,132 @@
<template>
<div v-if="users">
<b-table
:data="users.elements"
:loading="$apollo.queries.users.loading"
paginated
backend-pagination
backend-filtering
detailed
:show-detail-icon="true"
:total="users.total"
:per-page="USERS_PER_PAGE"
:has-detailed-visible="(row => row.actors.length > 0)"
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<template slot-scope="props">
<b-table-column field="id" width="40" numeric>
{{ props.row.id }}
</b-table-column>
<b-table-column field="email" :label="$t('Email')" searchable>
<template slot="searchable" slot-scope="props">
<b-input
v-model="props.filters.email"
placeholder="Search..."
icon="magnify"
size="is-small"
/>
</template>
<router-link
:to="{ name: RouteName.ADMIN_USER_PROFILE, params: { id: props.row.id } }"
:class="{ disabled: props.row.disabled }"
>
{{ props.row.email }}
</router-link>
</b-table-column>
<b-table-column field="confirmedAt" :label="$t('Confirmed at')" :centered="true">
{{ props.row.confirmedAt | formatDateTimeString }}
</b-table-column>
<b-table-column field="locale" :label="$t('Language')" :centered="true">
{{ props.row.locale }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<router-link
v-for="actor in props.row.actors"
:key="actor.id"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: actor.id } }"
>
<article class="media">
<figure class="media-left">
<p class="image is-64x64">
<img :src="actor.avatar.url" />
</p>
</figure>
<div class="media-content">
<div class="content">
<strong v-if="actor.name">{{ actor.name }}</strong>
<small>@{{ actor.preferredUsername }}</small>
<p>{{ actor.summary }}</p>
</div>
</div>
</article>
</router-link>
</template>
</b-table>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { LIST_USERS } from "../../graphql/user";
import RouteName from "../../router/name";
const USERS_PER_PAGE = 10;
@Component({
apollo: {
users: {
query: LIST_USERS,
variables() {
return {
email: this.email,
page: 1,
limit: USERS_PER_PAGE,
};
},
},
},
})
export default class Users extends Vue {
page = 1;
email = "";
USERS_PER_PAGE = USERS_PER_PAGE;
RouteName = RouteName;
async onPageChange(page: number) {
this.page = page;
await this.$apollo.queries.users.fetchMore({
variables: {
email: this.email,
page: this.page,
limit: USERS_PER_PAGE,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newFollowings = fetchMoreResult.users.elements;
return {
users: {
__typename: previousResult.users.__typename,
total: previousResult.users.total,
elements: [...previousResult.users.elements, ...newFollowings],
},
};
},
});
}
onFiltersChange({ email }: { email: string }) {
this.email = email;
}
}
</script>
<style lang="scss" scoped>
@import "../../variables.scss";
a.disabled {
color: $danger;
text-decoration: line-through;
}
</style>

View file

@ -9,7 +9,11 @@
tag="span" tag="span"
path="{actor} closed {report}" path="{actor} closed {report}"
> >
<span slot="actor">@{{ log.actor.preferredUsername }}</span> <router-link
slot="actor"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.actor.id } }"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link <router-link
:to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }" :to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }"
slot="report" slot="report"
@ -21,7 +25,11 @@
tag="span" tag="span"
path="{actor} reopened {report}" path="{actor} reopened {report}"
> >
<span slot="actor">@{{ log.actor.preferredUsername }}</span> <router-link
slot="actor"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.actor.id } }"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link <router-link
:to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }" :to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }"
slot="report" slot="report"
@ -33,7 +41,11 @@
tag="span" tag="span"
path="{actor} marked {report} as resolved" path="{actor} marked {report} as resolved"
> >
<span slot="actor">@{{ log.actor.preferredUsername }}</span> <router-link
slot="actor"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.actor.id } }"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link <router-link
:to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }" :to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }"
slot="report" slot="report"
@ -45,7 +57,11 @@
tag="span" tag="span"
path="{actor} added a note on {report}" path="{actor} added a note on {report}"
> >
<span slot="actor">@{{ log.actor.preferredUsername }}</span> <router-link
slot="actor"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.actor.id } }"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link <router-link
v-if="log.object.report" v-if="log.object.report"
:to="{ name: RouteName.REPORT, params: { reportId: log.object.report.id } }" :to="{ name: RouteName.REPORT, params: { reportId: log.object.report.id } }"
@ -59,8 +75,28 @@
tag="span" tag="span"
path='{actor} deleted an event named "{title}"' path='{actor} deleted an event named "{title}"'
> >
<span slot="actor">@{{ log.actor.preferredUsername }}</span> <router-link
<span slot="title">{{ log.object.title }}</span> slot="actor"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.actor.id } }"
>@{{ log.actor.preferredUsername }}</router-link
>
<b slot="title">{{ log.object.title }}</b>
</i18n>
<i18n
v-else-if="log.action === ActionLogAction.ACTOR_SUSPENSION"
tag="span"
path="{actor} suspended profile {profile}"
>
<router-link
slot="actor"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.actor.id } }"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: log.object.id } }"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n> </i18n>
<br /> <br />
<small>{{ log.insertedAt | formatDateTimeString }}</small> <small>{{ log.insertedAt | formatDateTimeString }}</small>
@ -78,6 +114,7 @@ import { IActionLog, ActionLogAction } from "@/types/report.model";
import { LOGS } from "@/graphql/report"; import { LOGS } from "@/graphql/report";
import ReportCard from "@/components/Report/ReportCard.vue"; import ReportCard from "@/components/Report/ReportCard.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { displayNameAndUsername } from "../../types/actor";
@Component({ @Component({
components: { components: {
@ -95,6 +132,8 @@ export default class ReportList extends Vue {
ActionLogAction = ActionLogAction; ActionLogAction = ActionLogAction;
RouteName = RouteName; RouteName = RouteName;
displayNameAndUsername = displayNameAndUsername;
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -32,8 +32,8 @@
<td> <td>
<router-link <router-link
:to="{ :to="{
name: RouteName.PROFILE, name: RouteName.ADMIN_PROFILE,
params: { name: report.reported.preferredUsername }, params: { id: report.reported.id },
}" }"
> >
<img <img
@ -53,8 +53,8 @@
<td v-else> <td v-else>
<router-link <router-link
:to="{ :to="{
name: RouteName.PROFILE, name: RouteName.ADMIN_PROFILE,
params: { name: report.reporter.preferredUsername }, params: { id: report.reporter.id },
}" }"
> >
<img <img
@ -139,7 +139,7 @@
> >
</div> </div>
<ul v-for="comment in report.comments" v-if="report.comments.length > 0"> <ul v-for="comment in report.comments" v-if="report.comments.length > 0" :key="comment.id">
<li> <li>
<div class="box" v-if="comment"> <div class="box" v-if="comment">
<article class="media"> <article class="media">
@ -173,11 +173,9 @@
</ul> </ul>
<h2 class="title" v-if="report.notes.length > 0">{{ $t("Notes") }}</h2> <h2 class="title" v-if="report.notes.length > 0">{{ $t("Notes") }}</h2>
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`"> <div class="box note" v-for="note in report.notes" :id="`note-${note.id}`" :key="note.id">
<p>{{ note.content }}</p> <p>{{ note.content }}</p>
<router-link <router-link :to="{ name: RouteName.ADMIN_PROFILE, params: { id: note.moderator.id } }">
:to="{ name: RouteName.PROFILE, params: { name: note.moderator.preferredUsername } }"
>
<img alt class="image" :src="note.moderator.avatar.url" v-if="note.moderator.avatar" /> <img alt class="image" :src="note.moderator.avatar.url" v-if="note.moderator.avatar" />
@{{ note.moderator.preferredUsername }} @{{ note.moderator.preferredUsername }}
</router-link> </router-link>

View file

@ -122,6 +122,14 @@ export default class Settings extends Vue {
}, },
], ],
}, },
{
title: this.$t("Users") as string,
to: { name: RouteName.USERS } as Route,
},
{
title: this.$t("Profiles") as string,
to: { name: RouteName.PROFILES } as Route,
},
], ],
}, },
]; ];

View file

@ -423,7 +423,8 @@ defmodule Mobilizon.Federation.ActivityPub do
"to" => [url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"] "to" => [url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
} }
with {:ok, %Oban.Job{}} <- Actors.delete_actor(actor), # We completely delete the actor if activity is remote
with {:ok, %Oban.Job{}} <- Actors.delete_actor(actor, reserve_username: local),
{:ok, activity} <- create_activity(data, local), {:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, actor} {:ok, activity, actor}

View file

@ -59,7 +59,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
Logger.info("Handle incoming to create notes") Logger.info("Handle incoming to create notes")
with object_data <- with object_data when is_map(object_data) <-
object |> Converter.Comment.as_to_model_data(), object |> Converter.Comment.as_to_model_data(),
{:existing_comment, {:error, :comment_not_found}} <- {:existing_comment, {:error, :comment_not_found}} <-
{:existing_comment, Conversations.get_comment_from_url_with_preload(object_data.url)}, {:existing_comment, Conversations.get_comment_from_url_with_preload(object_data.url)},
@ -87,7 +87,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Event"} = object}) do def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Event"} = object}) do
Logger.info("Handle incoming to create event") Logger.info("Handle incoming to create event")
with object_data <- with object_data when is_map(object_data) <-
object |> Converter.Event.as_to_model_data(), object |> Converter.Event.as_to_model_data(),
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(object_data.url)}, {:existing_event, nil} <- {:existing_event, Events.get_event_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Event{} = event} <- {:ok, %Activity{} = activity, %Event{} = event} <-

View file

@ -36,7 +36,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
Logger.debug(inspect(object)) Logger.debug(inspect(object))
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"), with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:ok, %Actor{id: actor_id, domain: domain}} <- {:ok, %Actor{id: actor_id, domain: domain, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(author_url), ActivityPub.get_or_fetch_actor_by_url(author_url),
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(Map.get(object, "tag", []))}, {:tags, tags} <- {:tags, ConverterUtils.fetch_tags(Map.get(object, "tag", []))},
{:mentions, mentions} <- {:mentions, mentions} <-
@ -90,6 +90,9 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
data data
end end
else
{:ok, %Actor{suspended: true}} ->
:error
end end
end end

View file

@ -38,7 +38,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
Logger.debug(inspect(object)) Logger.debug(inspect(object))
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"), with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:actor, {:ok, %Actor{id: actor_id, domain: actor_domain}}} <- {:actor, {:ok, %Actor{id: actor_id, domain: actor_domain, suspended: false}}} <-
{:actor, ActivityPub.get_or_fetch_actor_by_url(author_url)}, {:actor, ActivityPub.get_or_fetch_actor_by_url(author_url)},
{:address, address_id} <- {:address, address_id} <-
{:address, get_address(object["location"])}, {:address, get_address(object["location"])},
@ -87,6 +87,9 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
updated_at: object["updated"], updated_at: object["updated"],
publish_at: object["published"] publish_at: object["published"]
} }
else
{:ok, %Actor{suspended: true}} ->
:error
end end
end end

View file

@ -16,6 +16,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Service.Statistics alias Mobilizon.Service.Statistics
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
require Logger
def list_action_logs( def list_action_logs(
_parent, _parent,
@ -96,6 +97,20 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
} }
end end
defp transform_action_log(Actor, :suspend, %ActionLog{changes: changes}) do
%{
action: :actor_suspension,
object: convert_changes_to_struct(Actor, changes)
}
end
defp transform_action_log(Actor, :unsuspend, %ActionLog{changes: changes}) do
%{
action: :actor_unsuspension,
object: convert_changes_to_struct(Actor, changes)
}
end
# Changes are stored as %{"key" => "value"} so we need to convert them back as struct # Changes are stored as %{"key" => "value"} so we need to convert them back as struct
defp convert_changes_to_struct(struct, %{"report_id" => _report_id} = changes) do defp convert_changes_to_struct(struct, %{"report_id" => _report_id} = changes) do
with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}), with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}),

View file

@ -3,10 +3,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
Handles the person-related GraphQL calls Handles the person-related GraphQL calls
""" """
import Mobilizon.Users.Guards
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Admin
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.Participant alias Mobilizon.Events.Participant
alias Mobilizon.Storage.Page
alias Mobilizon.Users alias Mobilizon.Users
alias Mobilizon.Users.User alias Mobilizon.Users.User
@ -17,8 +21,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
@doc """ @doc """
Get a person Get a person
""" """
def get_person(_parent, %{id: id}, _resolution) do def get_person(_parent, %{id: id}, %{context: %{current_user: %User{role: role}}}) do
with %Actor{} = actor <- Actors.get_actor_with_preload(id), with %Actor{suspended: suspended} = actor <- Actors.get_actor_with_preload(id, true),
true <- suspended == false or is_moderator(role),
actor <- proxify_pictures(actor) do actor <- proxify_pictures(actor) do
{:ok, actor} {:ok, actor}
else else
@ -41,6 +46,30 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end end
end end
def list_persons(
_parent,
%{
preferred_username: preferred_username,
name: name,
domain: domain,
local: local,
suspended: suspended,
page: page,
limit: limit
},
%{
context: %{current_user: %User{role: role}}
}
)
when is_moderator(role) do
{:ok,
Actors.list_actors(:Person, preferred_username, name, domain, local, suspended, page, limit)}
end
def list_persons(_parent, _args, _resolution) do
{:error, "You need to be logged-in and a moderator to list persons"}
end
@doc """ @doc """
Returns the current actor for the currently logged-in user Returns the current actor for the currently logged-in user
""" """
@ -201,25 +230,41 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
with {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id), with {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id),
{:no_participant, {:ok, %Participant{} = participant}} <- {:no_participant, {:ok, %Participant{} = participant}} <-
{:no_participant, Events.get_participant(event_id, actor_id)} do {:no_participant, Events.get_participant(event_id, actor_id)} do
{:ok, [participant]} {:ok, %Page{elements: [participant], total: 1}}
else else
{:is_owned, nil} -> {:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"} {:error, "Actor id is not owned by authenticated user"}
{:no_participant, _} -> {:no_participant, _} ->
{:ok, []} {:ok, %Page{elements: [], total: 0}}
end end
end end
@doc """ @doc """
Returns the list of events this person is going to Returns the list of events this person is going to
""" """
def person_participations(%Actor{id: actor_id}, _args, %{context: %{current_user: user}}) do def person_participations(%Actor{id: actor_id} = actor, %{page: page, limit: limit}, %{
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), context: %{current_user: %User{role: role} = user}
participations <- Events.list_event_participations_for_actor(actor) do }) do
{:ok, participations} {:is_owned, actor_found} = User.owns_actor(user, actor_id)
res =
cond do
not is_nil(actor_found) ->
true
is_moderator(role) ->
true
true ->
false
end
with {:is_owned, true} <- {:is_owned, res},
%Page{} = page <- Events.list_event_participations_for_actor(actor, page, limit) do
{:ok, page}
else else
{:is_owned, nil} -> {:is_owned, false} ->
{:error, "Actor id is not owned by authenticated user"} {:error, "Actor id is not owned by authenticated user"}
end end
end end
@ -243,6 +288,95 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
|> proxify_banner |> proxify_banner
end end
def user_for_person(%Actor{type: :Person, user_id: user_id}, _args, %{
context: %{current_user: %User{role: role}}
})
when is_moderator(role) do
with false <- is_nil(user_id),
%User{} = user <- Users.get_user(user_id) do
{:ok, user}
else
true ->
{:ok, nil}
_ ->
{:error, "User not found"}
end
end
def user_for_person(_, _args, _resolution), do: {:error, nil}
def organized_events_for_person(
%Actor{user_id: actor_user_id} = actor,
%{page: page, limit: limit},
%{
context: %{current_user: %User{id: user_id, role: role}}
}
) do
with true <- actor_user_id == user_id or is_moderator(role),
%Page{} = page <- Events.list_organized_events_for_actor(actor, page, limit) do
{:ok, page}
end
end
def suspend_profile(_parent, %{id: id}, %{
context: %{current_user: %User{role: role} = user}
})
when is_moderator(role) do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(user)},
%Actor{suspended: false} = actor <- Actors.get_remote_actor_with_preload(id),
{:ok, _} <- Actors.delete_actor(actor),
{:ok, _} <- Admin.log_action(moderator_actor, "suspend", actor) do
{:ok, actor}
else
{:moderator_actor, nil} ->
{:error, "No actor found for the moderator user"}
%Actor{suspended: true} ->
{:error, "Actor already suspended"}
nil ->
{:error, "No remote profile found with this ID"}
{:error, _} ->
{:error, "Error while performing background task"}
end
end
def suspend_profile(_parent, _args, _resolution) do
{:error, "Only moderators and administrators can suspend a profile"}
end
def unsuspend_profile(_parent, %{id: id}, %{
context: %{current_user: %User{role: role} = user}
})
when is_moderator(role) do
with {:moderator_actor, %Actor{} = moderator_actor} <-
{:moderator_actor, Users.get_actor_for_user(user)},
%Actor{preferred_username: preferred_username, domain: domain} = actor <-
Actors.get_remote_actor_with_preload(id, true),
{:ok, _} <- Actors.update_actor(actor, %{suspended: false}),
{:ok, %Actor{} = actor} <-
ActivityPub.make_actor_from_nickname("#{preferred_username}@#{domain}"),
{:ok, _} <- Admin.log_action(moderator_actor, "unsuspend", actor) do
{:ok, actor}
else
{:moderator_actor, nil} ->
{:error, "No actor found for the moderator user"}
nil ->
{:error, "No remote profile found with this ID"}
{:error, _} ->
{:error, "Error while performing background task"}
end
end
def unsuspend_profile(_parent, _args, _resolution) do
{:error, "Only moderators and administrators can unsuspend a profile"}
end
# We check that the actor is not the last administrator/creator of a group # We check that the actor is not the last administrator/creator of a group
@spec last_admin_of_a_group?(integer()) :: boolean() @spec last_admin_of_a_group?(integer()) :: boolean()
defp last_admin_of_a_group?(actor_id) do defp last_admin_of_a_group?(actor_id) do

View file

@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
alias Mobilizon.{Actors, Config, Events, Users} alias Mobilizon.{Actors, Config, Events, Users}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Crypto alias Mobilizon.Crypto
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User} alias Mobilizon.Users.{Setting, User}
@ -20,8 +21,11 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
@doc """ @doc """
Find an user by its ID Find an user by its ID
""" """
def find_user(_parent, %{id: id}, _resolution) do def find_user(_parent, %{id: id}, %{context: %{current_user: %User{role: role}}})
Users.get_user_with_actors(id) when is_moderator(role) do
with {:ok, %User{} = user} <- Users.get_user_with_actors(id) do
{:ok, user}
end
end end
@doc """ @doc """
@ -38,19 +42,16 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
@doc """ @doc """
List instance users List instance users
""" """
def list_and_count_users( def list_users(
_parent, _parent,
%{page: page, limit: limit, sort: sort, direction: direction}, %{email: email, page: page, limit: limit, sort: sort, direction: direction},
%{context: %{current_user: %User{role: role}}} %{context: %{current_user: %User{role: role}}}
) )
when is_moderator(role) do when is_moderator(role) do
total = Task.async(&Users.count_users/0) {:ok, Users.list_users(email, page, limit, sort, direction)}
elements = Task.async(fn -> Users.list_users(page, limit, sort, direction) end)
{:ok, %{total: Task.await(total), elements: Task.await(elements)}}
end end
def list_and_count_users(_parent, _args, _resolution) do def list_users(_parent, _args, _resolution) do
{:error, "You need to have admin access to list users"} {:error, "You need to have admin access to list users"}
end end
@ -242,9 +243,9 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
def user_participations( def user_participations(
%User{id: user_id}, %User{id: user_id},
args, args,
%{context: %{current_user: %User{id: logged_user_id}}} %{context: %{current_user: %User{id: logged_user_id, role: role}}}
) do ) do
with true <- user_id == logged_user_id, with true <- user_id == logged_user_id or is_moderator(role),
%Page{} = page <- %Page{} = page <-
Events.list_participations_for_user( Events.list_participations_for_user(
user_id, user_id,
@ -379,27 +380,58 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
def delete_account(_parent, %{password: password}, %{ def delete_account(_parent, %{password: password}, %{
context: %{current_user: %User{password_hash: password_hash} = user} context: %{current_user: %User{password_hash: password_hash} = user}
}) do }) do
with {:current_password, true} <- case {:current_password, Argon2.verify_pass(password, password_hash)} do
{:current_password, Argon2.verify_pass(password, password_hash)}, {:current_password, true} ->
actors <- Users.get_actors_for_user(user), do_delete_account(user)
# Detach actors from user
:ok <- Enum.each(actors, fn actor -> Actors.update_actor(actor, %{user_id: nil}) end),
# Launch a background job to delete actors
:ok <- Enum.each(actors, &Actors.delete_actor/1),
# Delete user
{:ok, user} <- Users.delete_user(user) do
{:ok, user}
else
{:current_password, false} -> {:current_password, false} ->
{:error, "The password provided is invalid"} {:error, "The password provided is invalid"}
end end
end end
def delete_account(_parent, %{user_id: user_id}, %{
context: %{current_user: %User{role: role}}
})
when is_moderator(role) do
with %User{} = user <- Users.get_user(user_id) do
do_delete_account(%User{} = user)
end
end
def delete_account(_parent, _args, _resolution) do def delete_account(_parent, _args, _resolution) do
{:error, "You need to be logged-in to delete your account"} {:error, "You need to be logged-in to delete your account"}
end end
defp do_delete_account(%User{} = user) do
with actors <- Users.get_actors_for_user(user),
activated <- not is_nil(user.confirmed_at),
# Detach actors from user
:ok <-
if(activated,
do: :ok,
else: Enum.each(actors, fn actor -> Actors.update_actor(actor, %{user_id: nil}) end)
),
# Launch a background job to delete actors
:ok <-
Enum.each(actors, fn actor ->
ActivityPub.delete(actor, true)
end),
# Delete user
{:ok, user} <- Users.delete_user(user, reserve_email: activated) do
{:ok, user}
end
end
@spec user_settings(User.t(), map(), map()) :: {:ok, list(Setting.t())} | {:error, String.t()} @spec user_settings(User.t(), map(), map()) :: {:ok, list(Setting.t())} | {:error, String.t()}
def user_settings(%User{} = user, _args, %{
context: %{current_user: %User{role: role}}
})
when is_moderator(role) do
with {:setting, settings} <- {:setting, Users.get_setting(user)} do
{:ok, settings}
end
end
def user_settings(%User{id: user_id} = user, _args, %{ def user_settings(%User{id: user_id} = user, _args, %{
context: %{current_user: %User{id: logged_user_id}} context: %{current_user: %User{id: logged_user_id}}
}) do }) do

View file

@ -17,9 +17,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
Represents a person identity Represents a person identity
""" """
object :person do object :person do
interfaces([:actor]) interfaces([:actor, :action_log_object])
field(:id, :id, description: "Internal ID for this person") field(:id, :id, description: "Internal ID for this person")
field(:user, :user, description: "The user this actor is associated to")
field(:user, :user,
description: "The user this actor is associated to",
resolve: &Person.user_for_person/3
)
field(:member_of, list_of(:member), description: "The list of groups this person is member of") field(:member_of, list_of(:member), description: "The list of groups this person is member of")
@ -52,16 +56,21 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
) )
# This one should have a privacy setting # This one should have a privacy setting
field(:organized_events, list_of(:event), field(:organized_events, :paginated_event_list,
resolve: dataloader(Events),
description: "A list of the events this actor has organized" description: "A list of the events this actor has organized"
) ) do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Person.organized_events_for_person/3)
end
@desc "The list of events this person goes to" @desc "The list of events this person goes to"
field(:participations, list_of(:participant), field(:participations, :paginated_participant_list,
description: "The list of events this person goes to" description: "The list of events this person goes to"
) do ) do
arg(:event_id, :id) arg(:event_id, :id)
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Person.person_participations/3) resolve(&Person.person_participations/3)
end end
@ -95,6 +104,17 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field :identities, list_of(:person) do field :identities, list_of(:person) do
resolve(&Person.identities/3) resolve(&Person.identities/3)
end end
field :persons, :persons do
arg(:preferred_username, :string, default_value: "")
arg(:name, :string, default_value: "")
arg(:domain, :string, default_value: "")
arg(:local, :boolean, default_value: true)
arg(:suspended, :boolean, default_value: false)
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Person.list_persons/3)
end
end end
object :person_mutations do object :person_mutations do
@ -168,6 +188,16 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
resolve(handle_errors(&Person.register_person/3)) resolve(handle_errors(&Person.register_person/3))
end end
field :suspend_profile, :deleted_object do
arg(:id, :id, description: "The profile ID to suspend")
resolve(&Person.suspend_profile/3)
end
field :unsuspend_profile, :person do
arg(:id, :id, description: "The profile ID to unsuspend")
resolve(&Person.unsuspend_profile/3)
end
end end
object :person_subscriptions do object :person_subscriptions do

View file

@ -5,6 +5,7 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Reports.{Note, Report} alias Mobilizon.Reports.{Note, Report}
@ -29,6 +30,8 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
value(:event_deletion) value(:event_deletion)
value(:comment_deletion) value(:comment_deletion)
value(:event_update) value(:event_update)
value(:actor_suspension)
value(:actor_unsuspension)
end end
@desc "The objects that can be in an action log" @desc "The objects that can be in an action log"
@ -48,6 +51,9 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
%Comment{}, _ -> %Comment{}, _ ->
:comment :comment
%Actor{type: "Person"}, _ ->
:person
_, _ -> _, _ ->
nil nil
end) end)

View file

@ -18,7 +18,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
field(:id, non_null(:id), description: "The user's ID") field(:id, non_null(:id), description: "The user's ID")
field(:email, non_null(:string), description: "The user's email") field(:email, non_null(:string), description: "The user's email")
field(:profiles, non_null(list_of(:person)), field(:actors, non_null(list_of(:person)),
description: "The user's list of profiles (identities)" description: "The user's list of profiles (identities)"
) )
@ -51,6 +51,8 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
field(:locale, :string, description: "The user's locale") field(:locale, :string, description: "The user's locale")
field(:disabled, :boolean, description: "Whether the user is disabled")
field(:participations, :paginated_participant_list, field(:participations, :paginated_participant_list,
description: "The list of participations this user has" description: "The list of participations this user has"
) do ) do
@ -144,13 +146,14 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
@desc "List instance users" @desc "List instance users"
field :users, :users do field :users, :users do
arg(:email, :string, default_value: "")
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
arg(:sort, :sortable_user_field, default_value: :id) arg(:sort, :sortable_user_field, default_value: :id)
arg(:direction, :sort_direction, default_value: :desc) arg(:direction, :sort_direction, default_value: :desc)
resolve(&User.list_and_count_users/3) resolve(&User.list_users/3)
end end
end end
@ -233,7 +236,8 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
@desc "Delete an account" @desc "Delete an account"
field :delete_account, :deleted_object do field :delete_account, :deleted_object do
arg(:password, non_null(:string)) arg(:password, :string)
arg(:user_id, :id)
resolve(&User.delete_account/3) resolve(&User.delete_account/3)
end end

View file

@ -34,7 +34,7 @@ defmodule Mix.Tasks.Mobilizon.Actors.Refresh do
#{total} actors to process #{total} actors to process
""") """)
query = from(a in Actor, where: not is_nil(a.domain)) query = from(a in Actor, where: not is_nil(a.domain) and not a.suspended)
{:ok, _res} = {:ok, _res} =
Repo.transaction( Repo.transaction(

View file

@ -14,21 +14,24 @@ defmodule Mix.Tasks.Mobilizon.Users.Delete do
OptionParser.parse( OptionParser.parse(
rest, rest,
strict: [ strict: [
assume_yes: :boolean assume_yes: :boolean,
force: :boolean
], ],
aliases: [ aliases: [
y: :assume_yes y: :assume_yes,
f: :force
] ]
) )
assume_yes? = Keyword.get(options, :assume_yes, false) assume_yes? = Keyword.get(options, :assume_yes, false)
force? = Keyword.get(options, :force, false)
Mix.Task.run("app.start") Mix.Task.run("app.start")
with {:ok, %User{} = user} <- Users.get_user_by_email(email), with {:ok, %User{} = user} <- Users.get_user_by_email(email),
true <- assume_yes? or Mix.shell().yes?("Continue with deleting user #{user.email}?"), true <- assume_yes? or Mix.shell().yes?("Continue with deleting user #{user.email}?"),
{:ok, %User{} = user} <- {:ok, %User{} = user} <-
Users.delete_user(user) do Users.delete_user(user, reserve_email: !force?) do
Mix.shell().info(""" Mix.shell().info("""
The user #{user.email} has been deleted The user #{user.email} has been deleted
""") """)

View file

@ -73,9 +73,9 @@ defmodule Mobilizon.Actors do
Gets an actor with preloaded relations. Gets an actor with preloaded relations.
""" """
@spec get_actor_with_preload(integer | String.t()) :: Actor.t() | nil @spec get_actor_with_preload(integer | String.t()) :: Actor.t() | nil
def get_actor_with_preload(id) do def get_actor_with_preload(id, include_suspended \\ false) do
id id
|> actor_with_preload_query() |> actor_with_preload_query(include_suspended)
|> Repo.one() |> Repo.one()
end end
@ -90,6 +90,14 @@ defmodule Mobilizon.Actors do
|> Repo.one() |> Repo.one()
end end
@spec get_remote_actor_with_preload(integer | String.t(), boolean()) :: Actor.t() | nil
def get_remote_actor_with_preload(id, include_suspended \\ false) do
id
|> actor_with_preload_query(include_suspended)
|> filter_external()
|> Repo.one()
end
@doc """ @doc """
Gets an actor by its URL (ActivityPub ID). The `:preload` option allows to Gets an actor by its URL (ActivityPub ID). The `:preload` option allows to
preload the followers relation. preload the followers relation.
@ -255,27 +263,41 @@ defmodule Mobilizon.Actors do
end end
end end
def delete_actor(%Actor{} = actor) do @delete_actor_default_options [reserve_username: true]
Workers.Background.enqueue("delete_actor", %{"actor_id" => actor.id})
def delete_actor(%Actor{} = actor, options \\ @delete_actor_default_options) do
delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
Workers.Background.enqueue("delete_actor", %{
"actor_id" => actor.id,
"reserve_username" => Keyword.get(delete_actor_options, :reserve_username, true)
})
end end
@doc """ @doc """
Deletes an actor. Deletes an actor.
""" """
@spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} @spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def perform(:delete_actor, %Actor{} = actor) do def perform(:delete_actor, %Actor{} = actor, options \\ @delete_actor_default_options) do
actor = Repo.preload(actor, @actor_preloads) actor = Repo.preload(actor, @actor_preloads)
transaction = delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
multi =
Multi.new() Multi.new()
|> Multi.run(:delete_organized_events, fn _, _ -> delete_actor_organized_events(actor) end) |> Multi.run(:delete_organized_events, fn _, _ -> delete_actor_organized_events(actor) end)
|> Multi.run(:empty_comments, fn _, _ -> delete_actor_empty_comments(actor) end) |> Multi.run(:empty_comments, fn _, _ -> delete_actor_empty_comments(actor) end)
|> Multi.run(:remove_banner, fn _, _ -> remove_banner(actor) end) |> Multi.run(:remove_banner, fn _, _ -> remove_banner(actor) end)
|> Multi.run(:remove_avatar, fn _, _ -> remove_avatar(actor) end) |> Multi.run(:remove_avatar, fn _, _ -> remove_avatar(actor) end)
|> Multi.update(:actor, Actor.delete_changeset(actor))
|> Repo.transaction()
case transaction do multi =
if Keyword.get(delete_actor_options, :reserve_username, true) do
Multi.update(multi, :actor, Actor.delete_changeset(actor))
else
Multi.delete(multi, :actor, actor)
end
case Repo.transaction(multi) do
{:ok, %{actor: %Actor{} = actor}} -> {:ok, %{actor: %Actor{} = actor}} ->
{:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}") {:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}")
{:ok, actor} {:ok, actor}
@ -295,8 +317,57 @@ defmodule Mobilizon.Actors do
@doc """ @doc """
Returns the list of actors. Returns the list of actors.
""" """
@spec list_actors :: [Actor.t()] @spec list_actors(String.t(), String.t(), boolean, boolean, integer, integer) :: Page.t()
def list_actors, do: Repo.all(Actor) def list_actors(
type \\ :Person,
preferred_username \\ "",
name \\ "",
domain \\ "",
local \\ true,
suspended \\ false,
page \\ nil,
limit \\ nil
)
def list_actors(
:Person,
preferred_username,
name,
domain,
local,
suspended,
page,
limit
) do
person_query()
|> filter_suspended(suspended)
|> filter_preferred_username(preferred_username)
|> filter_name(name)
|> filter_domain(domain)
|> filter_remote(local)
|> Page.build_page(page, limit)
end
defp filter_preferred_username(query, ""), do: query
defp filter_preferred_username(query, preferred_username),
do: where(query, [a], ilike(a.preferred_username, ^"%#{preferred_username}%"))
defp filter_name(query, ""), do: query
defp filter_name(query, name),
do: where(query, [a], ilike(a.name, ^"%#{name}%"))
defp filter_domain(query, ""), do: query
defp filter_domain(query, domain),
do: where(query, [a], ilike(a.domain, ^"%#{domain}%"))
defp filter_remote(query, true), do: filter_local(query)
defp filter_remote(query, false), do: filter_external(query)
defp filter_suspended(query, true), do: where(query, [a], a.suspended)
defp filter_suspended(query, false), do: where(query, [a], not a.suspended)
@doc """ @doc """
Returns the list of local actors by their username. Returns the list of local actors by their username.
@ -945,13 +1016,19 @@ defmodule Mobilizon.Actors do
changeset changeset
end end
@spec actor_with_preload_query(integer | String.t()) :: Ecto.Query.t() @spec actor_with_preload_query(integer | String.t(), boolean()) :: Ecto.Query.t()
defp actor_with_preload_query(actor_id) do defp actor_with_preload_query(actor_id, include_suspended \\ false)
from(
a in Actor, defp actor_with_preload_query(actor_id, false) do
where: a.id == ^actor_id and not a.suspended, actor_id
preload: [:organized_events, :followers, :followings] |> actor_with_preload_query(true)
) |> where([a], not a.suspended)
end
defp actor_with_preload_query(actor_id, true) do
Actor
|> where([a], a.id == ^actor_id)
|> preload([a], [:organized_events, :followers, :followings])
end end
@spec actor_by_username_query(String.t()) :: Ecto.Query.t() @spec actor_by_username_query(String.t()) :: Ecto.Query.t()
@ -1000,6 +1077,11 @@ defmodule Mobilizon.Actors do
) )
end end
@spec person_query :: Ecto.Query.t()
defp person_query do
from(a in Actor, where: a.type == ^:Person)
end
@spec group_query :: Ecto.Query.t() @spec group_query :: Ecto.Query.t()
defp group_query do defp group_query do
from(a in Actor, where: a.type == ^:Group) from(a in Actor, where: a.type == ^:Group)

View file

@ -16,7 +16,9 @@ defmodule Mobilizon.Admin do
defenum(ActionLogAction, [ defenum(ActionLogAction, [
"update", "update",
"create", "create",
"delete" "delete",
"suspend",
"unsuspend"
]) ])
alias Ecto.Multi alias Ecto.Multi

View file

@ -401,6 +401,14 @@ defmodule Mobilizon.Events do
{:ok, events, events_count} {:ok, events, events_count}
end end
@spec list_organized_events_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_organized_events_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> event_for_actor_query()
|> preload_for_event()
|> Page.build_page(page, limit)
end
@spec list_organized_events_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t() @spec list_organized_events_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_organized_events_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do def list_organized_events_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id group_id
@ -842,13 +850,11 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Returns the list of participations for an actor. Returns the list of participations for an actor.
""" """
@spec list_event_participations_for_actor(Actor.t(), integer | nil, integer | nil) :: @spec list_event_participations_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
[Participant.t()]
def list_event_participations_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do def list_event_participations_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id actor_id
|> event_participations_for_actor_query() |> event_participations_for_actor_query()
|> Page.paginate(page, limit) |> Page.build_page(page, limit)
|> Repo.all()
end end
@doc """ @doc """
@ -1414,7 +1420,8 @@ defmodule Mobilizon.Events do
join: e in Event, join: e in Event,
on: p.event_id == e.id, on: p.event_id == e.id,
where: p.actor_id == ^actor_id and p.role != ^:not_approved, where: p.actor_id == ^actor_id and p.role != ^:not_approved,
preload: [:event] preload: [:event],
order_by: [desc: e.begins_on]
) )
end end

View file

@ -18,6 +18,7 @@ defmodule Mobilizon.Media.File do
@optional_attrs [:content_type, :size] @optional_attrs [:content_type, :size]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@derive Jason.Encoder
embedded_schema do embedded_schema do
field(:name, :string) field(:name, :string)
field(:url, :string) field(:url, :string)

View file

@ -25,6 +25,7 @@ defmodule Mobilizon.Users.User do
reset_password_token: String.t(), reset_password_token: String.t(),
locale: String.t(), locale: String.t(),
default_actor: Actor.t(), default_actor: Actor.t(),
disabled: boolean(),
actors: [Actor.t()], actors: [Actor.t()],
feed_tokens: [FeedToken.t()] feed_tokens: [FeedToken.t()]
} }
@ -40,7 +41,8 @@ defmodule Mobilizon.Users.User do
:reset_password_sent_at, :reset_password_sent_at,
:reset_password_token, :reset_password_token,
:locale, :locale,
:unconfirmed_email :unconfirmed_email,
:disabled
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@ -64,6 +66,7 @@ defmodule Mobilizon.Users.User do
field(:reset_password_token, :string) field(:reset_password_token, :string)
field(:unconfirmed_email, :string) field(:unconfirmed_email, :string)
field(:locale, :string, default: "en") field(:locale, :string, default: "en")
field(:disabled, :boolean, default: false)
belongs_to(:default_actor, Actor) belongs_to(:default_actor, Actor)
has_many(:actors, Actor) has_many(:actors, Actor)
@ -91,6 +94,13 @@ defmodule Mobilizon.Users.User do
end end
end end
def delete_changeset(%__MODULE__{} = user) do
user
|> change()
|> put_change(:disabled, true)
|> put_change(:default_actor_id, nil)
end
@doc false @doc false
@spec registration_changeset(t, map) :: Ecto.Changeset.t() @spec registration_changeset(t, map) :: Ecto.Changeset.t()
def registration_changeset(%__MODULE__{} = user, attrs) do def registration_changeset(%__MODULE__{} = user, attrs) do

View file

@ -8,8 +8,10 @@ defmodule Mobilizon.Users do
import Mobilizon.Storage.Ecto import Mobilizon.Storage.Ecto
alias Ecto.Multi
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.FeedToken
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User} alias Mobilizon.Users.{Setting, User}
@ -46,7 +48,8 @@ defmodule Mobilizon.Users do
@spec get_user!(integer | String.t()) :: User.t() @spec get_user!(integer | String.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id) def get_user!(id), do: Repo.get!(User, id)
@spec get_user(integer | String.t()) :: User.t() | nil @spec get_user(integer | String.t() | nil) :: User.t() | nil
def get_user(nil), do: nil
def get_user(id), do: Repo.get(User, id) def get_user(id), do: Repo.get(User, id)
def get_user_with_settings!(id) do def get_user_with_settings!(id) do
@ -105,11 +108,35 @@ defmodule Mobilizon.Users do
end end
end end
@delete_user_default_options [reserve_email: true]
@doc """ @doc """
Deletes an user. Deletes an user.
""" """
@spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def delete_user(%User{} = user), do: Repo.delete(user) def delete_user(%User{id: user_id} = user, options \\ @delete_user_default_options) do
delete_user_options = Keyword.merge(@delete_user_default_options, options)
multi =
Multi.new()
|> Multi.delete_all(:settings, from(s in Setting, where: s.user_id == ^user_id))
|> Multi.delete_all(:feed_tokens, from(f in FeedToken, where: f.user_id == ^user_id))
multi =
if Keyword.get(delete_user_options, :reserve_email, true) do
Multi.update(multi, :user, User.delete_changeset(user))
else
Multi.delete(multi, :user, user)
end
case Repo.transaction(multi) do
{:ok, %{user: %User{} = user}} ->
{:ok, user}
{:error, remove, error, _} when remove in [:settings, :feed_tokens] ->
{:error, error}
end
end
@doc """ @doc """
Get an user with its actors Get an user with its actors
@ -196,12 +223,22 @@ defmodule Mobilizon.Users do
@doc """ @doc """
Returns the list of users. Returns the list of users.
""" """
@spec list_users(integer | nil, integer | nil, atom | nil, atom | nil) :: [User.t()] @spec list_users(String.t(), integer | nil, integer | nil, atom | nil, atom | nil) :: Page.t()
def list_users(page \\ nil, limit \\ nil, sort \\ nil, direction \\ nil) do def list_users(email \\ "", page \\ nil, limit \\ nil, sort \\ nil, direction \\ nil)
def list_users("", page, limit, sort, direction) do
User User
|> Page.paginate(page, limit)
|> sort(sort, direction) |> sort(sort, direction)
|> Repo.all() |> preload([u], [:actors, :feed_tokens, :settings, :default_actor])
|> Page.build_page(page, limit)
end
def list_users(email, page, limit, sort, direction) do
User
|> where([u], ilike(u.email, ^"%#{email}%"))
|> sort(sort, direction)
|> preload([u], [:actors, :feed_tokens, :settings, :default_actor])
|> Page.build_page(page, limit)
end end
@doc """ @doc """

View file

@ -10,6 +10,7 @@ defmodule Mobilizon.Service.Export.Feed do
alias Mobilizon.{Actors, Events, Users} alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Web.{Endpoint, MediaProxy} alias Mobilizon.Web.{Endpoint, MediaProxy}
@ -148,12 +149,12 @@ defmodule Mobilizon.Service.Export.Feed do
end end
defp fetch_identity_participations(%Actor{} = actor) do defp fetch_identity_participations(%Actor{} = actor) do
with events <- Events.list_event_participations_for_actor(actor) do with %Page{} = page <- Events.list_event_participations_for_actor(actor) do
events page
end end
end end
defp participations_to_events(participations) do defp participations_to_events(%Page{elements: participations}) do
participations participations
|> Enum.map(& &1.event_id) |> Enum.map(& &1.event_id)
|> Enum.map(&Events.get_event_with_preload!/1) |> Enum.map(&Events.get_event_with_preload!/1)

View file

@ -7,6 +7,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
@doc """ @doc """
@ -123,7 +124,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
end end
end end
defp participations_to_events(participations) do defp participations_to_events(%Page{elements: participations}) do
participations participations
|> Enum.map(& &1.event_id) |> Enum.map(& &1.event_id)
|> Enum.map(&Events.get_event_with_preload!/1) |> Enum.map(&Events.get_event_with_preload!/1)

View file

@ -9,9 +9,11 @@ defmodule Mobilizon.Service.Workers.Background do
use Mobilizon.Service.Workers.Helper, queue: "background" use Mobilizon.Service.Workers.Helper, queue: "background"
@impl Oban.Worker @impl Oban.Worker
def perform(%{"op" => "delete_actor", "actor_id" => actor_id}, _job) do def perform(%{"op" => "delete_actor", "actor_id" => actor_id} = args, _job) do
with %Actor{} = actor <- Actors.get_actor(actor_id) do with reserve_username when is_boolean(reserve_username) <-
Actors.perform(:delete_actor, actor) Map.get(args, "reserve_username", true),
%Actor{} = actor <- Actors.get_actor(actor_id) do
Actors.perform(:delete_actor, actor, reserve_username: reserve_username)
end end
end end

View file

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddDisabledParameterToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add(:disabled, :boolean, default: false, null: false)
end
end
end

View file

@ -12,9 +12,8 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
import ExUnit.CaptureLog import ExUnit.CaptureLog
import Mock import Mock
alias Mobilizon.{Actors, Conversations, Events, Resources, Tombstone} alias Mobilizon.{Actors, Conversations, Events}
alias Mobilizon.Actors.{Actor, Member} alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Collections.Resource
alias Mobilizon.Conversations.Comment alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Resources.Resource alias Mobilizon.Resources.Resource
@ -557,7 +556,6 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
assert is_nil(resource.parent_id) assert is_nil(resource.parent_id)
end end
@parent_url "http://mobilizon1.com/resource/e4ce71bd-6bcc-4c61-9774-7999dde0cc68"
test "it accepts incoming resources that are in a folder" do test "it accepts incoming resources that are in a folder" do
creator = creator =
insert(:actor, insert(:actor,
@ -978,12 +976,13 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
{:ok, _activity, _actor} = Transmogrifier.handle_incoming(data) {:ok, _activity, _actor} = Transmogrifier.handle_incoming(data)
assert %{success: 1, failure: 0} == Oban.drain_queue(:background) assert %{success: 1, failure: 0} == Oban.drain_queue(:background)
assert {:ok, %Actor{suspended: true}} = Actors.get_actor_by_url(url) assert {:error, :actor_not_found} = Actors.get_actor_by_url(url)
assert {:error, :event_not_found} = Events.get_event(event1.id) assert {:error, :event_not_found} = Events.get_event(event1.id)
assert %Tombstone{} = Tombstone.find_tombstone(event1_url) # Tombstone are cascade deleted, seems correct for now
# assert %Tombstone{} = Tombstone.find_tombstone(event1_url)
assert %Comment{deleted_at: deleted_at} = Conversations.get_comment(comment1.id) assert %Comment{deleted_at: deleted_at} = Conversations.get_comment(comment1.id)
refute is_nil(deleted_at) refute is_nil(deleted_at)
assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) # assert %Tombstone{} = Tombstone.find_tombstone(comment1_url)
end end
test "it fails for incoming actor deletes with spoofed origin" do test "it fails for incoming actor deletes with spoofed origin" do

View file

@ -273,16 +273,18 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
person(id: $actor_id) { person(id: $actor_id) {
id, id,
participations(eventId: $event_id) { participations(eventId: $event_id) {
id, elements {
role, id,
actor { role,
id actor {
}, id
event { },
id event {
id
}
} }
} }
} }
} }
""" """
@ -295,7 +297,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
) )
assert res["errors"] == nil assert res["errors"] == nil
assert res["data"]["person"]["participations"] == [] assert res["data"]["person"]["participations"]["elements"] == []
end end
test "create_event/3 creates an event with options", %{conn: conn, actor: actor, user: user} do test "create_event/3 creates an event with options", %{conn: conn, actor: actor, user: user} do
@ -979,16 +981,18 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
person(id: "#{actor.id}") { person(id: "#{actor.id}") {
id, id,
participations(eventId: #{event.id}) { participations(eventId: #{event.id}) {
id, elements {
role, id,
actor { role,
id actor {
}, id
event { },
id event {
id
}
} }
} }
} }
} }
""" """
@ -998,7 +1002,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["person"]["participations"] == [] assert json_response(res, 200)["data"]["person"]["participations"]["elements"] == []
mutation = """ mutation = """
mutation { mutation {
@ -1042,15 +1046,17 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
person(id: "#{actor.id}") { person(id: "#{actor.id}") {
id, id,
participations(eventId: #{event.id}) { participations(eventId: #{event.id}) {
role, elements {
actor { role,
id actor {
}, id
event { },
id event {
id
}
} }
} }
} }
} }
""" """
@ -1061,7 +1067,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["person"]["participations"] == [ assert json_response(res, 200)["data"]["person"]["participations"]["elements"] == [
%{ %{
"actor" => %{"id" => to_string(actor.id)}, "actor" => %{"id" => to_string(actor.id)},
"event" => %{"id" => to_string(event.id)}, "event" => %{"id" => to_string(event.id)},

View file

@ -201,6 +201,23 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
"Event with this ID \"1042\" doesn't exist" "Event with this ID \"1042\" doesn't exist"
end end
@person_participations """
query PersonParticipations($actorId: ID!, $eventId: ID) {
person(id: $actorId) {
participations(eventId: $eventId) {
total,
elements {
event {
uuid,
title
},
role
}
}
}
}
"""
test "actor_leave_event/3 should delete a participant from an event", %{ test "actor_leave_event/3 should delete a participant from an event", %{
conn: conn, conn: conn,
user: user, user: user,
@ -241,26 +258,15 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] == assert json_response(res, 200)["data"]["leaveEvent"]["actor"]["id"] ==
to_string(participant2.actor.id) to_string(participant2.actor.id)
query = """
{
person(id: "#{actor.id}") {
participations(eventId: "#{event.id}") {
event {
uuid,
title
},
role
}
}
}
"""
res = res =
conn conn
|> auth_conn(user) |> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> AbsintheHelpers.graphql_query(
query: @person_participations,
variables: %{actorId: actor.id, eventId: event.id}
)
assert json_response(res, 200)["data"]["person"]["participations"] == [ assert res["data"]["person"]["participations"]["elements"] == [
%{ %{
"event" => %{ "event" => %{
"uuid" => event.uuid, "uuid" => event.uuid,
@ -270,26 +276,15 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
} }
] ]
query = """
{
person(id: "#{actor2.id}") {
participations(eventId: "#{event.id}") {
event {
uuid,
title
},
role
}
}
}
"""
res = res =
conn conn
|> auth_conn(user2) |> auth_conn(user2)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> AbsintheHelpers.graphql_query(
query: @person_participations,
variables: %{actorId: actor2.id, eventId: event.id}
)
assert json_response(res, 200)["data"]["person"]["participations"] == [] assert res["data"]["person"]["participations"]["elements"] == []
end end
test "actor_leave_event/3 should check if the participant is the only creator", %{ test "actor_leave_event/3 should check if the participant is the only creator", %{

View file

@ -14,7 +14,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
@non_existent_username "nonexistent" @non_existent_username "nonexistent"
describe "Person Resolver" do describe "Person Resolver" do
test "get_person/3 returns a person by its username", context do test "get_person/3 returns a person by its username", %{conn: conn} do
user = insert(:user) user = insert(:user)
actor = insert(:actor, user: user) actor = insert(:actor, user: user)
@ -27,7 +27,8 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
""" """
res = res =
context.conn conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["preferredUsername"] == assert json_response(res, 200)["data"]["person"]["preferredUsername"] ==
@ -42,7 +43,8 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
""" """
res = res =
context.conn conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"] == nil assert json_response(res, 200)["data"]["person"] == nil
@ -506,7 +508,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
assert_enqueued( assert_enqueued(
worker: Workers.Background, worker: Workers.Background,
args: %{"actor_id" => person_id, "op" => "delete_actor"} args: %{"actor_id" => person_id, "op" => "delete_actor", "reserve_username" => true}
) )
assert %{success: 1, failure: 0} == Oban.drain_queue(:background) assert %{success: 1, failure: 0} == Oban.drain_queue(:background)
@ -538,9 +540,11 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
{ {
loggedPerson { loggedPerson {
participations { participations {
event { elements {
uuid, event {
title uuid,
title
}
} }
} }
} }
@ -552,7 +556,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
|> auth_conn(user) |> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "logged_person")) |> get("/api", AbsintheHelpers.query_skeleton(query, "logged_person"))
assert json_response(res, 200)["data"]["loggedPerson"]["participations"] == [] assert json_response(res, 200)["data"]["loggedPerson"]["participations"]["elements"] == []
event = insert(:event, %{organizer_actor: actor}) event = insert(:event, %{organizer_actor: actor})
insert(:participant, %{actor: actor, event: event}) insert(:participant, %{actor: actor, event: event})
@ -562,11 +566,27 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
|> auth_conn(user) |> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "logged_person")) |> get("/api", AbsintheHelpers.query_skeleton(query, "logged_person"))
assert json_response(res, 200)["data"]["loggedPerson"]["participations"] == [ assert json_response(res, 200)["data"]["loggedPerson"]["participations"]["elements"] == [
%{"event" => %{"title" => event.title, "uuid" => event.uuid}} %{"event" => %{"title" => event.title, "uuid" => event.uuid}}
] ]
end end
@person_participations """
query PersonParticipations($actorId: ID!) {
person(id: $actorId) {
participations {
total,
elements {
event {
uuid,
title
}
}
}
}
}
"""
test "find_person/3 can return the events an identity is going to if it's the same actor", test "find_person/3 can return the events an identity is going to if it's the same actor",
context do context do
user = insert(:user) user = insert(:user)
@ -574,47 +594,27 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
insert(:actor, user: user) insert(:actor, user: user)
actor_from_other_user = insert(:actor) actor_from_other_user = insert(:actor)
query = """ res =
{ context.conn
person(id: "#{actor.id}") { |> auth_conn(user)
participations { |> AbsintheHelpers.graphql_query(
event { query: @person_participations,
uuid, variables: %{actorId: actor.id}
title )
}
} assert res["data"]["person"]["participations"]["elements"] == []
}
}
"""
res = res =
context.conn context.conn
|> auth_conn(user) |> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> AbsintheHelpers.graphql_query(
query: @person_participations,
variables: %{actorId: actor_from_other_user.id}
)
assert json_response(res, 200)["data"]["person"]["participations"] == [] assert res["data"]["person"]["participations"]["elements"] == nil
query = """ assert hd(res["errors"])["message"] ==
{
person(id: "#{actor_from_other_user.id}") {
participations {
event {
uuid,
title
}
}
}
}
"""
res =
context.conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["participations"] == nil
assert hd(json_response(res, 200)["errors"])["message"] ==
"Actor id is not owned by authenticated user" "Actor id is not owned by authenticated user"
end end
@ -629,9 +629,11 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
{ {
person(id: "#{actor.id}") { person(id: "#{actor.id}") {
participations(eventId: "#{event.id}") { participations(eventId: "#{event.id}") {
event { elements {
uuid, event {
title uuid,
title
}
} }
} }
} }
@ -643,7 +645,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
|> auth_conn(user) |> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person")) |> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["data"]["person"]["participations"] == [ assert json_response(res, 200)["data"]["person"]["participations"]["elements"] == [
%{ %{
"event" => %{ "event" => %{
"uuid" => event.uuid, "uuid" => event.uuid,
@ -653,4 +655,114 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
] ]
end end
end end
describe "suspend_profile/3" do
@suspend_profile_mutation """
mutation SuspendProfile($id: ID!) {
suspendProfile(id: $id) {
id
}
}
"""
@person_query """
query Person($id: ID!) {
person(id: $id) {
id,
suspended
}
}
"""
@moderation_logs_query """
{
actionLogs {
action,
actor {
id,
preferredUsername
},
object {
...on Person {
id,
preferredUsername
}
}
}
}
"""
test "suspends a remote profile", %{conn: conn} do
modo = insert(:user, role: :moderator)
%Actor{id: modo_actor_id} = insert(:actor, user: modo)
%Actor{id: remote_profile_id} = insert(:actor, domain: "mobilizon.org", user: nil)
res =
conn
|> auth_conn(modo)
|> AbsintheHelpers.graphql_query(
query: @suspend_profile_mutation,
variables: %{id: remote_profile_id}
)
assert is_nil(res["errors"])
assert res["data"]["suspendProfile"]["id"] == to_string(remote_profile_id)
assert %{success: 1, failure: 0} == Oban.drain_queue(:background)
res =
conn
|> auth_conn(modo)
|> AbsintheHelpers.graphql_query(
query: @person_query,
variables: %{id: remote_profile_id}
)
assert res["data"]["person"]["suspended"] == true
res =
conn
|> auth_conn(modo)
|> AbsintheHelpers.graphql_query(query: @moderation_logs_query)
actionlog = hd(res["data"]["actionLogs"])
refute is_nil(actionlog)
assert actionlog["action"] == "ACTOR_SUSPENSION"
assert actionlog["actor"]["id"] == to_string(modo_actor_id)
assert actionlog["object"]["id"] == to_string(remote_profile_id)
end
test "doesn't suspend if profile is local", %{conn: conn} do
modo = insert(:user, role: :moderator)
%Actor{} = insert(:actor, user: modo)
%Actor{id: profile_id} = insert(:actor)
res =
conn
|> auth_conn(modo)
|> AbsintheHelpers.graphql_query(
query: @suspend_profile_mutation,
variables: %{id: profile_id}
)
assert hd(res["errors"])["message"] == "No remote profile found with this ID"
end
test "doesn't suspend if user is not at least moderator", %{conn: conn} do
fake_modo = insert(:user)
%Actor{} = insert(:actor, user: fake_modo)
%Actor{id: remote_profile_id} = insert(:actor, domain: "mobilizon.org", user: nil)
res =
conn
|> auth_conn(fake_modo)
|> AbsintheHelpers.graphql_query(
query: @suspend_profile_mutation,
variables: %{id: remote_profile_id}
)
assert hd(res["errors"])["message"] ==
"Only moderators and administrators can suspend a profile"
end
end
end end

View file

@ -57,8 +57,9 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
@valid_single_actor_params %{preferred_username: "test2", keys: "yolo"} @valid_single_actor_params %{preferred_username: "test2", keys: "yolo"}
describe "Resolver: Get an user" do describe "Resolver: Get an user" do
test "find_user/3 returns an user by its id", context do test "find_user/3 returns an user by its id", %{conn: conn} do
user = insert(:user) user = insert(:user)
modo = insert(:user, role: :moderator)
query = """ query = """
{ {
@ -69,7 +70,8 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
""" """
res = res =
context.conn conn
|> auth_conn(modo)
|> get("/api", AbsintheHelpers.query_skeleton(query, "user")) |> get("/api", AbsintheHelpers.query_skeleton(query, "user"))
assert json_response(res, 200)["data"]["user"]["email"] == user.email assert json_response(res, 200)["data"]["user"]["email"] == user.email
@ -83,7 +85,8 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
""" """
res = res =
context.conn conn
|> auth_conn(modo)
|> get("/api", AbsintheHelpers.query_skeleton(query, "user")) |> get("/api", AbsintheHelpers.query_skeleton(query, "user"))
assert json_response(res, 200)["data"]["user"] == nil assert json_response(res, 200)["data"]["user"] == nil
@ -1414,9 +1417,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
assert MapSet.new([actor1.id, actor2.id]) == MapSet.new([actor1_id, actor2_id]) assert MapSet.new([actor1.id, actor2.id]) == MapSet.new([actor1_id, actor2_id])
assert_raise Ecto.NoResultsError, fn -> assert Users.get_user(user.id).disabled == true
Users.get_user!(user.id)
end
assert %{success: 2, failure: 0} == Oban.drain_queue(:background) assert %{success: 2, failure: 0} == Oban.drain_queue(:background)

View file

@ -59,7 +59,8 @@ defmodule Mobilizon.ActorsTest do
end end
test "list_actors/0 returns all actors", %{actor: %Actor{id: actor_id}} do test "list_actors/0 returns all actors", %{actor: %Actor{id: actor_id}} do
assert actor_id == hd(Actors.list_actors()).id assert %Page{total: 1, elements: [%Actor{id: id}]} = Actors.list_actors()
assert id == actor_id
end end
test "get_actor!/1 returns the actor with given id", %{actor: %Actor{id: actor_id} = actor} do test "get_actor!/1 returns the actor with given id", %{actor: %Actor{id: actor_id} = actor} do
@ -316,7 +317,7 @@ defmodule Mobilizon.ActorsTest do
assert_enqueued( assert_enqueued(
worker: Workers.Background, worker: Workers.Background,
args: %{"actor_id" => actor.id, "op" => "delete_actor"} args: %{"actor_id" => actor.id, "op" => "delete_actor", "reserve_username" => true}
) )
assert %{success: 1, failure: 0} == Oban.drain_queue(:background) assert %{success: 1, failure: 0} == Oban.drain_queue(:background)

View file

@ -4,8 +4,7 @@ defmodule Mobilizon.EventsTest do
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.{Conversations, Events} alias Mobilizon.Events
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant, Session, Tag, TagRelation, Track} alias Mobilizon.Events.{Event, Participant, Session, Tag, TagRelation, Track}
alias Mobilizon.Service.Workers alias Mobilizon.Service.Workers
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page

View file

@ -1,6 +1,7 @@
defmodule Mobilizon.UsersTest do defmodule Mobilizon.UsersTest do
use Mobilizon.DataCase use Mobilizon.DataCase
alias Mobilizon.Storage.Page
alias Mobilizon.Users alias Mobilizon.Users
alias Mobilizon.Users.{Setting, User} alias Mobilizon.Users.{Setting, User}
import Mobilizon.Factory import Mobilizon.Factory
@ -13,7 +14,7 @@ defmodule Mobilizon.UsersTest do
test "list_users/0 returns all users" do test "list_users/0 returns all users" do
user = insert(:user) user = insert(:user)
users = Users.list_users(nil, nil, :id, :desc) %Page{elements: users, total: 1} = Users.list_users("", nil, nil, :id, :desc)
assert [user.id] == users |> Enum.map(& &1.id) assert [user.id] == users |> Enum.map(& &1.id)
end end
@ -52,9 +53,15 @@ defmodule Mobilizon.UsersTest do
assert user = Users.get_user!(user.id) assert user = Users.get_user!(user.id)
end end
test "delete_user/1 deletes the user" do test "delete_user/1 empties the user" do
user = insert(:user) user = insert(:user)
assert {:ok, %User{}} = Users.delete_user(user) assert {:ok, %User{}} = Users.delete_user(user)
assert Users.get_user(user.id).disabled == true
end
test "delete_user/1 deletes the user" do
user = insert(:user)
assert {:ok, %User{}} = Users.delete_user(user, reserve_email: false)
assert_raise Ecto.NoResultsError, fn -> Users.get_user!(user.id) end assert_raise Ecto.NoResultsError, fn -> Users.get_user!(user.id) end
end end

View file

@ -52,6 +52,12 @@ defmodule Mix.Tasks.Mobilizon.UsersTest do
test "delete existing user" do test "delete existing user" do
insert(:user, email: @email) insert(:user, email: @email)
Delete.run([@email, "-y"]) Delete.run([@email, "-y"])
assert {:ok, %User{disabled: true}} = Users.get_user_by_email(@email)
end
test "full delete existing user" do
insert(:user, email: @email)
Delete.run([@email, "-y", "-f"])
assert {:error, :user_not_found} == Users.get_user_by_email(@email) assert {:error, :user_not_found} == Users.get_user_by_email(@email)
end end