Introduce basic user and profile management

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-06-11 19:13:21 +02:00
parent da4ea84baf
commit beb35a09c6
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
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`
query($actorId: ID!) {
query(
$actorId: ID!
$organizedEventsPage: Int
$organizedEventsLimit: Int
$participationPage: Int
$participationLimit: Int
) {
person(id: $actorId) {
id
url
@ -51,10 +57,63 @@ export const GET_PERSON = gql`
feedTokens {
token
}
organizedEvents {
uuid
title
beginsOn
organizedEvents(page: $organizedEventsPage, limit: $organizedEventsLimit) {
total
elements {
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) {
id
participations(eventId: $eventId) {
id
role
actor {
id
}
event {
total
elements {
id
role
actor {
id
}
event {
id
}
}
}
}
@ -503,13 +506,16 @@ export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
eventPersonParticipationChanged(personId: $actorId) {
id
participations(eventId: $eventId) {
id
role
actor {
id
}
event {
total
elements {
id
role
actor {
id
}
event {
id
}
}
}
}

View File

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

View File

@ -64,8 +64,8 @@ export const VALIDATE_EMAIL = gql`
`;
export const DELETE_ACCOUNT = gql`
mutation DeleteAccount($password: String!) {
deleteAccount(password: $password) {
mutation DeleteAccount($password: String, $userId: ID!) {
deleteAccount(password: $password, userId: $userId) {
id
}
}
@ -134,3 +134,58 @@ export const SET_USER_SETTINGS = gql`
}
${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}.",
"Manage my settings": "Manage my 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}.",
"Manage my settings": "Gérer mes 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 Logs from "@/views/Moderation/Logs.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 {
SETTINGS = "SETTINGS",
@ -25,6 +29,10 @@ export enum SettingsRouteName {
RELAYS = "Relays",
RELAY_FOLLOWINGS = "Followings",
RELAY_FOLLOWERS = "Followers",
USERS = "USERS",
PROFILES = "PROFILES",
ADMIN_PROFILE = "ADMIN_PROFILE",
ADMIN_USER_PROFILE = "ADMIN_USER_PROFILE",
MODERATION = "MODERATION",
REPORTS = "Reports",
REPORT = "Report",
@ -87,6 +95,34 @@ export const settingsRoutes: RouteConfig[] = [
props: 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",
name: SettingsRouteName.RELAYS,

View File

@ -66,3 +66,10 @@ export function usernameWithDomain(actor: IActor): string {
}
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[];
participations: IParticipant[];
memberships: Paginate<IMember>;
user?: ICurrentUser;
}
export class Person extends Actor implements IPerson {
@ -26,6 +27,8 @@ export class Person extends Actor implements IPerson {
memberships!: Paginate<IMember>;
user!: ICurrentUser;
constructor(hash: IPerson | {} = {}) {
super(hash);

View File

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

View File

@ -39,11 +39,13 @@ export enum ActionLogAction {
REPORT_UPDATE_RESOLVED = "REPORT_UPDATE_RESOLVED",
EVENT_DELETION = "EVENT_DELETION",
COMMENT_DELETION = "COMMENT_DELETION",
ACTOR_SUSPENSION = "ACTOR_SUSPENSION",
ACTOR_UNSUSPENSION = "ACTOR_UNSUSPENSION",
}
export interface IActionLog {
id: string;
object: IReport | IReportNote | IEvent;
object: IReport | IReportNote | IEvent | IComment | IActor;
actor: IActor;
action: ActionLogAction;
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 class="tile is-parent is-vertical">
<article class="tile is-child box">
<p class="dashboard-number">{{ dashboard.numberOfUsers }}</p>
<p>{{ $t("Users") }}</p>
<router-link :to="{ name: RouteName.USERS }">
<p class="dashboard-number">{{ dashboard.numberOfUsers }}</p>
<p>{{ $t("Users") }}</p>
</router-link>
</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>{{ $t("Opened reports") }}</p>
</article>
</router-link>
</router-link>
</article>
</div>
</div>
<div class="tile is-parent" v-if="dashboard.lastPublicEventPublished">
@ -80,4 +82,10 @@ export default class Dashboard extends Vue {
font-weight: 700;
line-height: 1.125;
}
article.tile {
a {
color: #4a4a4a;
}
}
</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"
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
:to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }"
slot="report"
@ -21,7 +25,11 @@
tag="span"
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
:to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }"
slot="report"
@ -33,7 +41,11 @@
tag="span"
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
:to="{ name: RouteName.REPORT, params: { reportId: log.object.id } }"
slot="report"
@ -45,7 +57,11 @@
tag="span"
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
v-if="log.object.report"
:to="{ name: RouteName.REPORT, params: { reportId: log.object.report.id } }"
@ -59,8 +75,28 @@
tag="span"
path='{actor} deleted an event named "{title}"'
>
<span slot="actor">@{{ log.actor.preferredUsername }}</span>
<span slot="title">{{ log.object.title }}</span>
<router-link
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>
<br />
<small>{{ log.insertedAt | formatDateTimeString }}</small>
@ -78,6 +114,7 @@ import { IActionLog, ActionLogAction } from "@/types/report.model";
import { LOGS } from "@/graphql/report";
import ReportCard from "@/components/Report/ReportCard.vue";
import RouteName from "../../router/name";
import { displayNameAndUsername } from "../../types/actor";
@Component({
components: {
@ -95,6 +132,8 @@ export default class ReportList extends Vue {
ActionLogAction = ActionLogAction;
RouteName = RouteName;
displayNameAndUsername = displayNameAndUsername;
}
</script>
<style lang="scss" scoped>

View File

@ -32,8 +32,8 @@
<td>
<router-link
:to="{
name: RouteName.PROFILE,
params: { name: report.reported.preferredUsername },
name: RouteName.ADMIN_PROFILE,
params: { id: report.reported.id },
}"
>
<img
@ -53,8 +53,8 @@
<td v-else>
<router-link
:to="{
name: RouteName.PROFILE,
params: { name: report.reporter.preferredUsername },
name: RouteName.ADMIN_PROFILE,
params: { id: report.reporter.id },
}"
>
<img
@ -139,7 +139,7 @@
>
</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>
<div class="box" v-if="comment">
<article class="media">
@ -173,11 +173,9 @@
</ul>
<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>
<router-link
:to="{ name: RouteName.PROFILE, params: { name: note.moderator.preferredUsername } }"
>
<router-link :to="{ name: RouteName.ADMIN_PROFILE, params: { id: note.moderator.id } }">
<img alt class="image" :src="note.moderator.avatar.url" v-if="note.moderator.avatar" />
@{{ note.moderator.preferredUsername }}
</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"]
}
with {:ok, %Oban.Job{}} <- Actors.delete_actor(actor),
# We completely delete the actor if activity is remote