Improve admin views (2)

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2022-01-14 18:10:50 +01:00
parent ca6ef9b06b
commit 6e5061250c
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
108 changed files with 12041 additions and 2526 deletions

View File

@ -1,6 +1,36 @@
<template>
<div
class="p-4 bg-white rounded-lg shadow-md sm:p-8 dark:bg-gray-800 dark:border-gray-700 flex items-center space-x-4"
class="w-80 bg-white rounded-lg shadow-md p-4 sm:p-8 flex items-center space-x-4 flex-col items-center pb-10"
>
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
v-else
size="is-large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
:class="{ 'line-clamp-3': limit }"
v-html="actor.summary"
/>
</div>
<!-- <div
class="p-4 bg-white rounded-lg shadow-md sm:p-8 flex items-center space-x-4"
dir="auto"
>
<div class="flex-shrink-0">
@ -22,12 +52,10 @@
</div>
<div class="flex-1 min-w-0">
<h5
class="text-xl font-medium violet-title tracking-tight text-gray-900 dark:text-white"
>
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate dark:text-gray-400" v-if="actor.name">
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
@ -37,7 +65,7 @@
v-html="actor.summary"
/>
</div>
</div>
</div> -->
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";

View File

@ -1,6 +1,6 @@
<template>
<nav class="flex mb-3" :aria-label="$t('Breadcrumbs')">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<ol class="inline-flex items-center space-x-1 md:space-x-3 flex-wrap">
<li
class="inline-flex items-center"
v-for="(element, index) in links"
@ -10,7 +10,7 @@
<router-link
v-if="index === 0"
:to="element"
class="inline-flex items-center text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
class="inline-flex items-center text-gray-800 hover:text-gray-900"
>
{{ element.text }}
</router-link>
@ -28,7 +28,7 @@
></path>
</svg>
<span
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-400 md:ltr:ml-2 md:rtl:mr-2 dark:text-gray-500"
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-600 md:ltr:ml-2 md:rtl:mr-2"
>{{ element.text }}</span
>
</div>
@ -47,7 +47,7 @@
</svg>
<router-link
:to="element"
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-700 hover:text-gray-900 md:ltr:ml-2 md:rtl:mr-2 dark:text-gray-400 dark:hover:text-white"
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-800 hover:text-gray-900 md:ltr:ml-2 md:rtl:mr-2"
>{{ element.text }}</router-link
>
</div>

View File

@ -251,3 +251,26 @@ export const SAVE_ADMIN_SETTINGS = gql`
}
${ADMIN_SETTINGS_FRAGMENT}
`;
export const ADMIN_UPDATE_USER = gql`
mutation AdminUpdateUser(
$id: ID!
$email: String
$role: UserRole
$confirmed: Boolean
$notify: Boolean
) {
adminUpdateUser(
id: $id
email: $email
role: $role
confirmed: $confirmed
notify: $notify
) {
id
email
role
confirmedAt
}
}
`;

View File

@ -209,14 +209,30 @@ export const UPDATE_ACTIVITY_SETTING = gql`
`;
export const LIST_USERS = gql`
query ListUsers($email: String, $page: Int, $limit: Int) {
users(email: $email, page: $page, limit: $limit) {
query ListUsers(
$email: String
$currentSignInIp: String
$page: Int
$limit: Int
$sort: SortableUserField
$direction: SortDirection
) {
users(
email: $email
currentSignInIp: $currentSignInIp
page: $page
limit: $limit
sort: $sort
direction: $direction
) {
total
elements {
id
email
locale
confirmedAt
currentSignInIp
currentSignInAt
disabled
actors {
...ActorFragment

View File

@ -1286,5 +1286,26 @@
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.",
"Total number of participations": "Total number of participations",
"Uploaded media total size": "Uploaded media total size",
"0 Bytes": "0 Bytes"
"0 Bytes": "0 Bytes",
"Change email": "Change email",
"Confirm user": "Confirm user",
"Change role": "Change role",
"The user has been disabled": "The user has been disabled",
"This user doesn't have any profiles": "This user doesn't have any profiles",
"Edit user email": "Edit user email",
"Change user email": "Change user email",
"Previous email": "Previous email",
"Notify the user of the change": "Notify the user of the change",
"Change user role": "Change user role",
"Suspend the account?": "Suspend the account?",
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Do you really want to suspend this account? All of the user's profiles will be deleted.",
"Suspend the account": "Suspend the account",
"No user matches the filter": "No user matches the filter",
"new@email.com": "new@email.com",
"Other users with the same email domain": "Other users with the same email domain",
"Other users with the same IP address": "Other users with the same IP address",
"IP Address": "IP Address",
"Last seen on": "Last seen on",
"No user matches the filters": "No user matches the filters",
"Reset filters": "Reset filters"
}

View File

@ -288,7 +288,7 @@
"Either the participation request has already been validated, either the validation token is incorrect.": "Soit la demande de participation a déjà été validée, soit le jeton de validation est incorrect.",
"Element title": "Titre de l'élement",
"Element value": "Valeur de l'élement",
"Email": "Email",
"Email": "Courriel",
"Email address": "Adresse email",
"Email validate": "Validation de l'email",
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Les emails ne contiennent d'ordinaire pas de capitales, assurez-vous de n'avoir pas fait de faute de frappe.",
@ -1286,5 +1286,26 @@
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "Ce profil se situe sur cette instance, vous devez donc {access_the_corresponding_account} afin de le suspendre.",
"Total number of participations": "Nombre total de participations",
"Uploaded media total size": "Taille totale des médias téléversés",
"0 Bytes": "0 octets"
"0 Bytes": "0 octets",
"Change email": "Changer l'email",
"Confirm user": "Confirmer l'utilisateur⋅ice",
"Change role": "Changer le role",
"The user has been disabled": "L'utilisateur⋅ice a été désactivé",
"This user doesn't have any profiles": "Cet utilisateur⋅ice n'a aucun profil",
"Edit user email": "Éditer l'email de l'utilisateur⋅ice",
"Change user email": "Modifier l'email de l'utilisateur⋅ice",
"Previous email": "Email précédent",
"Notify the user of the change": "Notifier l'utilisateur du changement",
"Change user role": "Changer le role de l'utilisateur",
"Suspend the account?": "Suspendre le compte ?",
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Voulez-vous vraiment suspendre ce compte ? Tous les profils de cet⋅te utilisateur⋅ice seront supprimés.",
"Suspend the account": "Suspendre le compte",
"No user matches the filter": "Aucun⋅e utilisateur⋅ice ne correspond au filtre",
"new@email.com": "nouvel@email.com",
"Other users with the same email domain": "Autres utilisateur⋅ices avec le même domaine de courriel",
"Other users with the same IP address": "Autres utilisateur⋅ices avec la même adresse IP",
"IP Address": "Adresse IP",
"Last seen on": "Vu pour la dernière fois",
"No user matches the filters": "Aucun⋅e utilisateur⋅ice ne correspond aux filtres",
"Reset filters": "Réinitialiser les filtres"
}

View File

@ -15,7 +15,14 @@
]"
/>
<actor-card :actor="person" :full="true" :popover="false" :limit="false" />
<div class="flex justify-center">
<actor-card
:actor="person"
:full="true"
:popover="false"
:limit="false"
/>
</div>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
<div class="flex flex-col">
@ -27,14 +34,14 @@
<tr
v-for="{ key, value, link } in metadata"
:key="key"
class="odd:bg-white even:bg-gray-50 border-b odd:dark:bg-gray-800 even:dark:bg-gray-700 dark:border-gray-600"
class="odd:bg-white even:bg-gray-50 border-b"
>
<td class="py-4 px-2 whitespace-nowrap dark:text-white">
<td class="py-4 px-2 whitespace-nowrap">
{{ key }}
</td>
<td
v-if="link"
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap dark:text-white"
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
>
<router-link :to="link">
{{ value }}
@ -42,7 +49,7 @@
</td>
<td
v-else
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap dark:text-white"
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
>
{{ value }}
</td>
@ -72,7 +79,8 @@
</div>
<p v-else></p>
<div
class="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg dark:bg-blue-200 dark:text-blue-800"
v-if="person.user"
class="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg"
role="alert"
>
<i18n

View File

@ -16,75 +16,124 @@
/>
<section>
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
<h2 class="text-lg font-bold mb-3">{{ $t("Details") }}</h2>
<div class="flex flex-col">
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block py-2 min-w-full sm:px-2 lg:px-8">
<div class="overflow-x-auto sm:-mx-6">
<div class="inline-block py-2 min-w-full sm:px-2">
<div class="overflow-hidden shadow-md sm:rounded-lg">
<table v-if="metadata.length > 0" class="min-w-full">
<tbody>
<tr
class="odd:bg-white even:bg-gray-50 border-b odd:dark:bg-gray-800 even:dark:bg-gray-700 dark:border-gray-600"
v-for="{ key, value, link, elements, type } in metadata"
class="odd:bg-white even:bg-gray-50 border-b"
v-for="{ key, value, link, type } in metadata"
:key="key"
>
<td class="py-4 px-2 whitespace-nowrap dark:text-white">
<td class="py-4 px-2 whitespace-nowrap align-middle">
{{ key }}
</td>
<td
v-if="elements && elements.length > 0"
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap dark:text-white"
>
<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"
class="py-4 px-2 whitespace-nowrap dark:text-white"
>
{{ $t("None") }}
</td>
<td
v-else-if="link"
class="py-4 px-2 whitespace-nowrap dark:text-white"
>
<td v-if="link" class="py-4 px-2 whitespace-nowrap">
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td
v-else-if="type === 'code'"
class="py-4 px-2 whitespace-nowrap dark:text-white"
v-else-if="type === 'ip'"
class="py-4 px-2 whitespace-nowrap"
>
<code>{{ value }}</code>
</td>
<td
v-else-if="type === 'badge'"
class="py-4 px-2 whitespace-nowrap dark:text-white"
v-else-if="type === 'role'"
class="py-4 px-2 whitespace-nowrap"
>
<span
class="bg-red-100 text-red-800 text-sm font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-red-200 dark:text-red-900"
:class="{
'bg-red-100 text-red-800':
user.role == ICurrentUserRole.ADMINISTRATOR,
'bg-yellow-100 text-yellow-800':
user.role == ICurrentUserRole.MODERATOR,
'bg-blue-100 text-blue-800':
user.role == ICurrentUserRole.USER,
}"
class="text-sm font-medium mr-2 px-2.5 py-0.5 rounded"
>
{{ value }}
</span>
</td>
<td
v-else
class="py-4 px-2 whitespace-nowrap dark:text-white"
>
<td v-else class="py-4 px-2 align-middle">
{{ value }}
</td>
<td
v-if="type === 'email'"
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start"
>
<b-button
size="is-small"
v-if="!user.disabled"
@click="isEmailChangeModalActive = true"
type="is-text"
icon-left="pencil"
>{{ $t("Change email") }}</b-button
>
<b-button
tag="router-link"
:to="{
name: RouteName.USERS,
query: { emailFilter: `@${userEmailDomain}` },
}"
size="is-small"
type="is-text"
icon-left="magnify"
>{{
$t("Other users with the same email domain")
}}</b-button
>
</td>
<td
v-else-if="type === 'confirmed'"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
size="is-small"
v-if="!user.confirmedAt || !user.disabled"
@click="isConfirmationModalActive = true"
type="is-text"
icon-left="check"
>{{ $t("Confirm user") }}</b-button
>
</td>
<td
v-else-if="type === 'role'"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
size="is-small"
v-if="!user.disabled"
@click="isRoleChangeModalActive = true"
type="is-text"
icon-left="chevron-double-up"
>{{ $t("Change role") }}</b-button
>
</td>
<td
v-else-if="type === 'ip' && user.currentSignInIp"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
tag="router-link"
:to="{
name: RouteName.USERS,
query: { ipFilter: user.currentSignInIp },
}"
size="is-small"
type="is-text"
icon-left="web"
>{{
$t("Other users with the same IP address")
}}</b-button
>
</td>
<td v-else></td>
</tr>
</tbody>
</table>
@ -94,34 +143,193 @@
</div>
</section>
<section class="my-4">
<h2 class="text-lg font-bold">{{ $t("Actions") }}</h2>
<div class="buttons">
<b-button
@click="deleteAccount"
v-if="!user.disabled"
type="is-primary"
>{{ $t("Suspend") }}</b-button
>
</div>
</section>
<section class="my-4">
<h2 class="text-lg font-bold">{{ $t("Profiles") }}</h2>
<div class="flex gap-4 flex-wrap">
<h2 class="text-lg font-bold mb-3">{{ $t("Profiles") }}</h2>
<div
class="flex flex-wrap justify-center sm:justify-start gap-4"
v-if="profiles.length > 0"
>
<router-link
v-for="profile in profiles"
:key="profile.id"
class="flex-auto"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: profile.id } }"
>
<actor-card
:actor="profile"
:full="true"
:popover="false"
:limit="false"
:limit="true"
/>
</router-link>
</div>
<empty-content v-else-if="!$apollo.loading" :inline="true" icon="account">
{{ $t("This user doesn't have any profiles") }}
</empty-content>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">{{ $t("Actions") }}</h2>
<div class="buttons" v-if="!user.disabled">
<b-button @click="suspendAccount" type="is-danger">{{
$t("Suspend")
}}</b-button>
</div>
<div
v-else
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{{ $t("The user has been disabled") }}
</div>
</section>
<b-modal
:active="isEmailChangeModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="updateUserEmail">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Change user email") }}</p>
<button
type="button"
class="delete"
@click="isEmailChangeModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-field :label="$t('Previous email')">
<b-input type="email" :value="user.email" disabled> </b-input>
</b-field>
<b-field :label="$t('New email')">
<b-input
type="email"
v-model="newUser.email"
:placeholder="$t('new@email.com')"
required
>
</b-input>
</b-field>
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isEmailChangeModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Change email")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<b-modal
:active="isRoleChangeModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="updateUserRole">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Change user role") }}</p>
<button
type="button"
class="delete"
@click="isRoleChangeModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
</b-radio>
</b-field>
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.MODERATOR"
>
{{ $t("Moderator") }}
</b-radio>
</b-field>
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.USER"
>
{{ $t("User") }}
</b-radio>
</b-field>
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isRoleChangeModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Change role")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<b-modal
:active="isConfirmationModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="confirmUser">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Confirm user") }}</p>
<button
type="button"
class="delete"
@click="isConfirmationModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isConfirmationModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Confirm user")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
</div>
<empty-content v-else-if="!$apollo.loading" icon="account">
{{ $t("This user was not found") }}
@ -136,8 +344,7 @@
</empty-content>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { Route } from "vue-router";
import { Component, Vue, Prop, Watch } from "vue-property-decorator";
import { formatBytes } from "@/utils/datetime";
import { ICurrentUserRole } from "@/types/enums";
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
@ -146,7 +353,7 @@ import RouteName from "../../router/name";
import { IUser } from "../../types/current-user.model";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import { LANGUAGES_CODES } from "@/graphql/admin";
import { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
@Component({
apollo: {
@ -198,12 +405,28 @@ export default class AdminUserProfile extends Vue {
RouteName = RouteName;
ICurrentUserRole = ICurrentUserRole;
isEmailChangeModalActive = false;
isRoleChangeModalActive = false;
isConfirmationModalActive = false;
newUser = {
email: "",
role: this?.user?.role,
confirm: false,
notify: true,
};
get metadata(): Array<Record<string, unknown>> {
if (!this.user) return [];
return [
{
key: this.$i18n.t("Email"),
value: this.user.email,
type: "email",
},
{
key: this.$i18n.t("Language"),
@ -214,7 +437,7 @@ export default class AdminUserProfile extends Vue {
{
key: this.$i18n.t("Role"),
value: this.roleName(this.user.role),
type: "badge",
type: "role",
},
{
key: this.$i18n.t("Login status"),
@ -228,6 +451,7 @@ export default class AdminUserProfile extends Vue {
this.$options.filters && this.user.confirmedAt
? this.$options.filters.formatDateTimeString(this.user.confirmedAt)
: this.$i18n.t("Not confirmed"),
type: "confirmed",
},
{
key: this.$i18n.t("Last sign-in"),
@ -241,7 +465,7 @@ export default class AdminUserProfile extends Vue {
{
key: this.$i18n.t("Last IP adress"),
value: this.user.currentSignInIp || this.$t("Unknown"),
type: "code",
type: this.user.currentSignInIp ? "ip" : undefined,
},
{
key: this.$i18n.t("Total number of participations"),
@ -270,14 +494,25 @@ export default class AdminUserProfile extends Vue {
}
}
async deleteAccount(): Promise<Route> {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_USER,
variables: {
userId: this.id,
async suspendAccount(): Promise<void> {
this.$buefy.dialog.confirm({
title: this.$t("Suspend the account?") as string,
message: this.$t(
"Do you really want to suspend this account? All of the user's profiles will be deleted."
) as string,
confirmText: this.$t("Suspend the account") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
onConfirm: async () => {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_USER,
variables: {
userId: this.id,
},
});
return this.$router.push({ name: RouteName.USERS });
},
});
return this.$router.push({ name: RouteName.USERS });
}
get profiles(): IActor[] {
@ -287,5 +522,61 @@ export default class AdminUserProfile extends Vue {
get languageCode(): string | undefined {
return this.user?.locale;
}
async confirmUser() {
this.isConfirmationModalActive = false;
await this.updateUser({
confirmed: true,
notify: this.newUser.notify,
});
}
async updateUserRole() {
this.isRoleChangeModalActive = false;
await this.updateUser({
role: this.newUser.role,
notify: this.newUser.notify,
});
}
async updateUserEmail() {
this.isEmailChangeModalActive = false;
await this.updateUser({
email: this.newUser.email,
notify: this.newUser.notify,
});
}
async updateUser(properties: {
email?: string;
notify: boolean;
confirmed?: boolean;
role?: ICurrentUserRole;
}) {
await this.$apollo.mutate<{ adminUpdateUser: IUser }>({
mutation: ADMIN_UPDATE_USER,
variables: {
id: this.id,
...properties,
},
});
}
@Watch("user")
resetCurrentUserRole(
updatedUser: IUser | undefined,
oldUser: IUser | undefined
) {
if (updatedUser?.role !== oldUser?.role) {
this.newUser.role = updatedUser?.role;
}
}
get userEmailDomain(): string | undefined {
if (this?.user?.email) {
return this?.user?.email.split("@")[1];
}
return undefined;
}
}
</script>

View File

@ -9,13 +9,12 @@
/>
<h1 class="text-2xl">{{ instance.domain }}</h1>
<div class="grid md:grid-cols-4 gap-2 content-center text-center mt-2">
<div class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800">
<div class="bg-gray-50 rounded-xl p-8">
<router-link
:to="{
name: RouteName.PROFILES,
query: { domain: instance.domain },
}"
class="dark:text-white hover:dark:text-slate-300"
>
<span class="mb-4 text-xl font-semibold block">{{
instance.personCount
@ -23,13 +22,12 @@
<span class="text-sm block">{{ $t("Profiles") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800">
<div class="bg-gray-50 rounded-xl p-8">
<router-link
:to="{
name: RouteName.ADMIN_GROUPS,
query: { domain: instance.domain },
}"
class="dark:text-white hover:dark:text-slate-300"
>
<span class="mb-4 text-xl font-semibold block">{{
instance.groupCount
@ -37,26 +35,21 @@
<span class="text-sm block">{{ $t("Groups") }}</span>
</router-link>
</div>
<div
class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800 dark:text-white hover:dark:text-slate-300"
>
<div class="bg-gray-50 rounded-xl p-8">
<span class="mb-4 text-xl font-semibold block">{{
instance.followingsCount
}}</span>
<span class="text-sm block">{{ $t("Followings") }}</span>
</div>
<div
class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800 dark:text-white hover:dark:text-slate-300"
>
<div class="bg-gray-50 rounded-xl p-8">
<span class="mb-4 text-xl font-semibold block">{{
instance.followersCount
}}</span>
<span class="text-sm block">{{ $t("Followers") }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800">
<div class="bg-gray-50 rounded-xl p-8">
<router-link
:to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }"
class="dark:text-white hover:dark:text-slate-300"
>
<span class="mb-4 text-xl font-semibold block">{{
instance.reportsCount
@ -64,9 +57,7 @@
<span class="text-sm block">{{ $t("Reports") }}</span>
</router-link>
</div>
<div
class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800 dark:text-white hover:dark:text-slate-300"
>
<div class="bg-gray-50 rounded-xl p-8">
<span class="mb-4 font-semibold block">{{
formatBytes(instance.mediaSize)
}}</span>

View File

@ -10,12 +10,27 @@
]"
/>
<div v-if="users">
<form @submit.prevent="activateFilters">
<b-field class="mb-5" grouped group-multiline>
<b-field :label="$t('Email')" expanded>
<b-input trap-focus icon="email" v-model="emailFilterFieldValue" />
</b-field>
<b-field :label="$t('IP Address')" expanded>
<b-input icon="web" v-model="ipFilterFieldValue" />
</b-field>
<p class="control self-end mb-0">
<b-button type="is-primary" native-type="submit">{{
$t("Filter")
}}</b-button>
</p>
</b-field>
</form>
<b-table
:data="users.elements"
:loading="$apollo.queries.users.loading"
paginated
backend-pagination
backend-filtering
:debounce-search="500"
:current-page.sync="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
@ -24,25 +39,14 @@
: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"
>
<b-table-column field="id" width="40" numeric v-slot="props">
{{ props.row.id }}
</b-table-column>
<b-table-column field="email" :label="$t('Email')" searchable>
<template #searchable="props">
<b-input
v-model="props.filters.email"
:aria-label="$t('Filter')"
:placeholder="$t('Filter')"
icon="magnify"
/>
</template>
<b-table-column field="email" :label="$t('Email')">
<template v-slot:default="props">
<router-link
class="user-profile"
:to="{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: props.row.id },
@ -55,13 +59,16 @@
</b-table-column>
<b-table-column
field="confirmedAt"
:label="$t('Confirmed at')"
:label="$t('Last seen on')"
:centered="true"
v-slot="props"
>
<template v-if="props.row.confirmedAt">
{{ props.row.confirmedAt | formatDateTimeString }}
<template v-if="props.row.currentSignInAt">
<time :datetime="props.row.currentSignInAt">
{{ props.row.currentSignInAt | formatDateTimeString }}
</time>
</template>
<template v-else-if="props.row.confirmedAt"> - </template>
<template v-else>
{{ $t("Not confirmed") }}
</template>
@ -74,6 +81,20 @@
>
{{ getLanguageNameForCode(props.row.locale) }}
</b-table-column>
<template #empty>
<empty-content
v-if="!$apollo.loading && emailFilter"
:inline="true"
icon="account"
>
{{ $t("No user matches the filters") }}
<template #desc>
<b-button type="is-primary" @click="resetFilters">
{{ $t("Reset filters") }}
</b-button>
</template>
</empty-content>
</template>
</b-table>
</div>
</div>
@ -86,6 +107,7 @@ import VueRouter from "vue-router";
import { LANGUAGES_CODES } from "@/graphql/admin";
import { IUser } from "@/types/current-user.model";
import { Paginate } from "@/types/paginate";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
const USERS_PER_PAGE = 10;
@ -97,7 +119,8 @@ const USERS_PER_PAGE = 10;
fetchPolicy: "cache-and-network",
variables() {
return {
email: this.email,
email: this.emailFilter,
currentSignInIp: this.ipFilter,
page: this.page,
limit: USERS_PER_PAGE,
};
@ -120,6 +143,9 @@ const USERS_PER_PAGE = 10;
title: this.$t("Users") as string,
};
},
components: {
EmptyContent,
},
})
export default class Users extends Vue {
USERS_PER_PAGE = USERS_PER_PAGE;
@ -129,6 +155,9 @@ export default class Users extends Vue {
users!: Paginate<IUser>;
languages!: Array<{ code: string; name: string }>;
emailFilterFieldValue = this.emailFilter;
ipFilterFieldValue = this.ipFilter;
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
@ -137,12 +166,20 @@ export default class Users extends Vue {
this.pushRouter({ page: page.toString() });
}
get email(): string {
return (this.$route.query.email as string) || "";
get emailFilter(): string {
return (this.$route.query.emailFilter as string) || "";
}
set email(email: string) {
this.pushRouter({ email });
set emailFilter(emailFilter: string) {
this.pushRouter({ emailFilter });
}
get ipFilter(): string {
return (this.$route.query.ipFilter as string) || "";
}
set ipFilter(ipFilter: string) {
this.pushRouter({ ipFilter });
}
get languagesCodes(): string[] {
@ -161,15 +198,23 @@ export default class Users extends Vue {
this.page = page;
await this.$apollo.queries.users.fetchMore({
variables: {
email: this.email,
email: this.emailFilter,
currentSignInIp: this.ipFilter,
page: this.page,
limit: USERS_PER_PAGE,
},
});
}
onFiltersChange({ email }: { email: string }): void {
this.email = email;
activateFilters(): void {
this.emailFilter = this.emailFilterFieldValue;
this.ipFilter = this.ipFilterFieldValue;
}
resetFilters(): void {
this.emailFilterFieldValue = "";
this.ipFilterFieldValue = "";
this.activateFilters();
}
private async pushRouter(args: Record<string, string>): Promise<void> {

View File

@ -14,9 +14,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Service.Statistics
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
import Mobilizon.Web.Gettext
require Logger
@ -281,6 +283,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
end
@spec update_user(any, map(), Absinthe.Resolution.t()) ::
{:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()}
| {:ok, Mobilizon.Users.User.t()}
def update_user(_parent, %{id: id, notify: notify} = args, %{
context: %{current_user: %User{role: role}}
})
@ -294,7 +299,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
[] ->
{:error, :invalid_argument}
[change, _] ->
[change | _] ->
case change do
:email -> change_email(user, Map.get(args, :email), notify)
:role -> change_role(user, Map.get(args, :role), notify)
@ -309,22 +314,24 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
dgettext("errors", "You need to be logged-in and an administrator to edit an user's details")}
end
@spec change_email(User.t(), String.t(), boolean())
@spec change_email(User.t(), String.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()}
defp change_email(%User{email: old_email} = user, new_email, notify) do
if Authenticator.can_change_email?(user) do
if new_email != old_email do
if Email.Checker.valid?(new_email) do
case Users.update_user_email(user, new_email) do
{:ok, %User{} = user} ->
user
|> Email.User.send_email_reset_old_email()
|> Email.Mailer.send_email_later()
case Users.update_user(user, %{email: new_email}) do
{:ok, %User{} = updated_user} ->
if notify do
updated_user
|> Email.Admin.user_email_change_old(old_email)
|> Email.Mailer.send_email_later()
user
|> Email.User.send_email_reset_new_email()
|> Email.Mailer.send_email_later()
updated_user
|> Email.Admin.user_email_change_new(old_email)
|> Email.Mailer.send_email_later()
end
{:ok, user}
{:ok, updated_user}
{:error, %Ecto.Changeset{} = err} ->
Logger.debug(inspect(err))
@ -339,26 +346,49 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
end
end
@spec change_role(User.t(), Mobilizon.Users.UserRole.t(), boolean()) ::
{:ok, User.t()} | {:error, String.t() | Ecto.Changeset.t()}
defp change_role(%User{role: old_role} = user, new_role, notify) do
if old_role != new_role do
Users.update_user(user, %{role: new_role})
with {:ok, %User{} = user} <- Users.update_user(user, %{role: new_role}) do
if notify do
user
|> Email.Admin.user_role_change(old_role)
|> Email.Mailer.send_email_later()
end
{:ok, user}
end
else
{:error, dgettext("errors", "The new role must be different")}
end
end
defp confirm_user(%User{confirmed_at: old_confirmed_at} = user, confirmed, notify) do
new_confirmed_at =
cond do
is_nil(old_confirmed_at) && confirmed ->
DateTime.utc_now()
match?(%DateTime{}, old_confirmed_at) && !confirmed ->
nil
true ->
old_confirmed_at
@spec confirm_user(User.t(), boolean(), boolean()) ::
{:ok, User.t()} | {:error, String.t() | Ecto.Changeset.t()}
defp confirm_user(%User{confirmed_at: nil} = user, true, notify) do
with {:ok, %User{} = user} <-
Users.update_user(user, %{
confirmed_at: DateTime.utc_now(),
confirmation_sent_at: nil,
confirmation_token: nil
}) do
if notify do
user
|> Email.Admin.user_confirmation()
|> Email.Mailer.send_email_later()
end
Users.update_user(user, %{confirmed_at: new_confirmed_at})
{:ok, user}
end
end
defp confirm_user(%User{confirmed_at: %DateTime{}} = _user, true, _notify) do
{:error, dgettext("errors", "Can't confirm an already confirmed user")}
end
defp confirm_user(_user, _confirm, _notify) do
{:error, dgettext("errors", "Deconfirming users is not supported")}
end
@spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) ::
@ -472,13 +502,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}} = resolution
)
when is_admin(role) do
case Relay.follow(domain) do
{:ok, _activity, _follow} ->
Instances.refresh()
get_instance(parent, args, resolution)
{:error, err} ->
{:error, err}
with {:ok, _activity, _follow} <- Relay.follow(domain) do
Instances.refresh()
get_instance(parent, args, resolution)
end
end
@ -486,12 +512,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:ok, Follower.t()} | {:error, any()}
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Relay.follow(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, err} ->
{:error, err}
with {:ok, _activity, follow} <- Relay.follow(address) do
{:ok, follow}
end
end
@ -499,12 +521,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:ok, Follower.t()} | {:error, any()}
def remove_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Relay.unfollow(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, err} ->
{:error, err}
with {:ok, _activity, follow} <- Relay.unfollow(address) do
{:ok, follow}
end
end
@ -516,12 +534,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}}
)
when is_admin(role) do
case Relay.accept(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, err} ->
{:error, err}
with {:ok, _activity, follow} <- Relay.accept(address) do
{:ok, follow}
end
end
@ -533,12 +547,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}}
)
when is_admin(role) do
case Relay.reject(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, err} ->
{:error, err}
with {:ok, _activity, follow} <- Relay.reject(address) do
{:ok, follow}
end
end

View File

@ -48,11 +48,11 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
{:ok, Page.t(User.t())} | {:error, :unauthorized}
def list_users(
_parent,
%{email: email, page: page, limit: limit, sort: sort, direction: direction},
args,
%{context: %{current_user: %User{role: role}}}
)
when is_moderator(role) do
{:ok, Users.list_users(email, page, limit, sort, direction)}
{:ok, Users.list_users(Keyword.new(args))}
end
def list_users(_parent, _args, _resolution) do