Improve admin views
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
d428d1ddf7
commit
ca6ef9b06b
@ -7,10 +7,6 @@
|
|||||||
@import "styles/vue-announcer.scss";
|
@import "styles/vue-announcer.scss";
|
||||||
@import "styles/vue-skip-to.scss";
|
@import "styles/vue-skip-to.scss";
|
||||||
|
|
||||||
// a {
|
|
||||||
// color: $violet-2;
|
|
||||||
// }
|
|
||||||
|
|
||||||
a.out,
|
a.out,
|
||||||
.content a,
|
.content a,
|
||||||
.ProseMirror a {
|
.ProseMirror a {
|
||||||
@ -19,18 +15,10 @@ a.out,
|
|||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// input.input {
|
|
||||||
// border-color: $input-border-color !important;
|
|
||||||
// }
|
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
padding: 1rem 1% 4rem;
|
padding: 1rem 1% 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
figure img.is-rounded {
|
|
||||||
border: 1px solid #cdcaea;
|
|
||||||
}
|
|
||||||
|
|
||||||
$color-black: #000;
|
$color-black: #000;
|
||||||
|
|
||||||
.mention {
|
.mention {
|
||||||
|
@ -1,22 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="media" style="align-items: top" dir="auto">
|
<div
|
||||||
<div class="media-left">
|
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"
|
||||||
<figure class="image is-32x32" v-if="actor.avatar">
|
dir="auto"
|
||||||
<img class="is-rounded" :src="actor.avatar.url" alt="" />
|
>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<figure class="w-12 h-12" v-if="actor.avatar">
|
||||||
|
<img
|
||||||
|
class="rounded-lg"
|
||||||
|
:src="actor.avatar.url"
|
||||||
|
alt=""
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
<b-icon v-else size="is-medium" icon="account-circle" />
|
<b-icon
|
||||||
|
v-else
|
||||||
|
size="is-large"
|
||||||
|
icon="account-circle"
|
||||||
|
class="ltr:-mr-0.5 rtl:-ml-0.5"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="media-content">
|
<div class="flex-1 min-w-0">
|
||||||
<p>
|
<h5
|
||||||
{{ actor.name || `@${usernameWithDomain(actor)}` }}
|
class="text-xl font-medium violet-title tracking-tight text-gray-900 dark:text-white"
|
||||||
</p>
|
>
|
||||||
<p class="has-text-grey-dark" v-if="actor.name">
|
{{ displayName(actor) }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-gray-500 truncate dark:text-gray-400" v-if="actor.name">
|
||||||
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
|
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="full"
|
v-if="full"
|
||||||
class="summary"
|
class="line-clamp-3"
|
||||||
:class="{ limit: limit }"
|
:class="{ limit: limit }"
|
||||||
v-html="actor.summary"
|
v-html="actor.summary"
|
||||||
/>
|
/>
|
||||||
@ -25,7 +41,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||||
import { IActor, usernameWithDomain } from "../../types/actor";
|
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class ActorCard extends Vue {
|
export default class ActorCard extends Vue {
|
||||||
@ -38,135 +54,7 @@ export default class ActorCard extends Vue {
|
|||||||
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
|
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
|
||||||
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
usernameWithDomain = usernameWithDomain;
|
||||||
|
|
||||||
|
displayName = displayName;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
|
||||||
.summary.limit {
|
|
||||||
max-width: 25rem;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "@/styles/_mixins" as *;
|
|
||||||
|
|
||||||
.media {
|
|
||||||
.media-left {
|
|
||||||
margin-right: initial;
|
|
||||||
@include margin-right(1rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
display: block !important;
|
|
||||||
z-index: 10000;
|
|
||||||
|
|
||||||
.tooltip-inner {
|
|
||||||
background: black;
|
|
||||||
color: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 5px 10px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-arrow {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-style: solid;
|
|
||||||
position: absolute;
|
|
||||||
margin: 5px;
|
|
||||||
border-color: black;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[x-placement^="top"] {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
|
|
||||||
.tooltip-arrow {
|
|
||||||
border-width: 5px 5px 0 5px;
|
|
||||||
border-left-color: transparent !important;
|
|
||||||
border-right-color: transparent !important;
|
|
||||||
border-bottom-color: transparent !important;
|
|
||||||
bottom: -5px;
|
|
||||||
left: calc(50% - 5px);
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[x-placement^="bottom"] {
|
|
||||||
margin-top: 5px;
|
|
||||||
|
|
||||||
.tooltip-arrow {
|
|
||||||
border-width: 0 5px 5px 5px;
|
|
||||||
border-left-color: transparent !important;
|
|
||||||
border-right-color: transparent !important;
|
|
||||||
border-top-color: transparent !important;
|
|
||||||
top: -5px;
|
|
||||||
left: calc(50% - 5px);
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[x-placement^="right"] {
|
|
||||||
@include margin-left(5px);
|
|
||||||
|
|
||||||
.tooltip-arrow {
|
|
||||||
border-width: 5px 5px 5px 0;
|
|
||||||
border-left-color: transparent !important;
|
|
||||||
border-top-color: transparent !important;
|
|
||||||
border-bottom-color: transparent !important;
|
|
||||||
left: -5px;
|
|
||||||
top: calc(50% - 5px);
|
|
||||||
@include margin-left(0);
|
|
||||||
@include margin-right(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[x-placement^="left"] {
|
|
||||||
@include margin-right(5px);
|
|
||||||
|
|
||||||
.tooltip-arrow {
|
|
||||||
border-width: 5px 0 5px 5px;
|
|
||||||
border-top-color: transparent !important;
|
|
||||||
border-right-color: transparent !important;
|
|
||||||
border-bottom-color: transparent !important;
|
|
||||||
right: -5px;
|
|
||||||
top: calc(50% - 5px);
|
|
||||||
@include margin-left(0);
|
|
||||||
@include margin-right(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.popover {
|
|
||||||
$color: #f9f9f9;
|
|
||||||
|
|
||||||
.popover-inner {
|
|
||||||
background: lighten($background-color, 65%);
|
|
||||||
color: black;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 5px 30px rgba(black, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-arrow {
|
|
||||||
border-color: $color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[aria-hidden="true"] {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, visibility 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[aria-hidden="false"] {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -34,12 +34,6 @@
|
|||||||
class="metadata-organized-by"
|
class="metadata-organized-by"
|
||||||
:title="$t('Organized by')"
|
:title="$t('Organized by')"
|
||||||
>
|
>
|
||||||
<popover-actor-card
|
|
||||||
:actor="event.organizerActor"
|
|
||||||
v-if="!event.attributedTo"
|
|
||||||
>
|
|
||||||
<actor-card :actor="event.organizerActor" />
|
|
||||||
</popover-actor-card>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="event.attributedTo"
|
v-if="event.attributedTo"
|
||||||
:to="{
|
:to="{
|
||||||
@ -49,23 +43,19 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<popover-actor-card
|
<actor-card
|
||||||
:actor="event.attributedTo"
|
|
||||||
v-if="
|
v-if="
|
||||||
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
|
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
|
||||||
"
|
"
|
||||||
>
|
:actor="event.attributedTo"
|
||||||
<actor-card :actor="event.attributedTo" />
|
/>
|
||||||
</popover-actor-card>
|
<actor-card v-else :actor="event.organizerActor" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<actor-card
|
||||||
<popover-actor-card
|
|
||||||
:actor="contact"
|
:actor="contact"
|
||||||
v-for="contact in event.contacts"
|
v-for="contact in event.contacts"
|
||||||
:key="contact.id"
|
:key="contact.id"
|
||||||
>
|
/>
|
||||||
<actor-card :actor="contact" />
|
|
||||||
</popover-actor-card>
|
|
||||||
</event-metadata-block>
|
</event-metadata-block>
|
||||||
<event-metadata-block
|
<event-metadata-block
|
||||||
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
|
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
|
||||||
|
@ -1279,5 +1279,12 @@
|
|||||||
"No instances match this filter. Try resetting filter fields?": "No instances match this filter. Try resetting filter fields?",
|
"No instances match this filter. Try resetting filter fields?": "No instances match this filter. Try resetting filter fields?",
|
||||||
"You haven't interacted with other instances yet.": "You haven't interacted with other instances yet.",
|
"You haven't interacted with other instances yet.": "You haven't interacted with other instances yet.",
|
||||||
"mobilizon-instance.tld": "mobilizon-instance.tld",
|
"mobilizon-instance.tld": "mobilizon-instance.tld",
|
||||||
"Report status": "Report status"
|
"Report status": "Report status",
|
||||||
|
"access the corresponding account": "access the corresponding account",
|
||||||
|
"Organized events": "Organized events",
|
||||||
|
"Memberships": "Memberships",
|
||||||
|
"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"
|
||||||
}
|
}
|
@ -1279,5 +1279,12 @@
|
|||||||
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
|
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
|
||||||
"You haven't interacted with other instances yet.": "Vous n'avez interagi avec encore aucune autre instance.",
|
"You haven't interacted with other instances yet.": "Vous n'avez interagi avec encore aucune autre instance.",
|
||||||
"mobilizon-instance.tld": "instance-mobilizon.tld",
|
"mobilizon-instance.tld": "instance-mobilizon.tld",
|
||||||
"Report status": "Statut du signalement"
|
"Report status": "Statut du signalement",
|
||||||
|
"access the corresponding account": "accéder au compte correspondant",
|
||||||
|
"Organized events": "Événements organisés",
|
||||||
|
"Memberships": "Adhésions",
|
||||||
|
"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"
|
||||||
}
|
}
|
@ -19,8 +19,8 @@ function localeShortWeekDayNames(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/18650828/10204399
|
// https://stackoverflow.com/a/18650828/10204399
|
||||||
function formatBytes(bytes: number, decimals = 2): string {
|
function formatBytes(bytes: number, decimals = 2, zero = "0 Bytes"): string {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return zero;
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
@ -143,11 +143,13 @@ $title-color: $violet-3;
|
|||||||
:root {
|
:root {
|
||||||
--color-primary: 30 125 151;
|
--color-primary: 30 125 151;
|
||||||
--color-secondary: 255 213 153;
|
--color-secondary: 255 213 153;
|
||||||
|
--color-violet-title: 66 64 86;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--color-primary: 30 125 151;
|
--color-primary: 30 125 151;
|
||||||
--color-secondary: 255 213 153;
|
--color-secondary: 255 213 153;
|
||||||
|
--color-violet-title: 66 64 86;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,49 +15,84 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="actor-card">
|
<actor-card :actor="person" :full="true" :popover="false" :limit="false" />
|
||||||
<actor-card
|
<section class="mt-4 mb-3">
|
||||||
:actor="person"
|
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
|
||||||
:full="true"
|
<div class="flex flex-col">
|
||||||
:popover="false"
|
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
:limit="false"
|
<div class="inline-block py-2 min-w-full sm:px-2 lg:px-8">
|
||||||
/>
|
<div class="overflow-hidden shadow-md sm:rounded-lg">
|
||||||
</div>
|
<table v-if="metadata.length > 0" class="min-w-full">
|
||||||
<table v-if="metadata.length > 0" class="table is-fullwidth">
|
<tbody>
|
||||||
<tbody>
|
<tr
|
||||||
<tr v-for="{ key, value, link } in metadata" :key="key">
|
v-for="{ key, value, link } in metadata"
|
||||||
<td>{{ key }}</td>
|
:key="key"
|
||||||
<td v-if="link">
|
class="odd:bg-white even:bg-gray-50 border-b odd:dark:bg-gray-800 even:dark:bg-gray-700 dark:border-gray-600"
|
||||||
<router-link :to="link">
|
>
|
||||||
{{ value }}
|
<td class="py-4 px-2 whitespace-nowrap dark:text-white">
|
||||||
</router-link>
|
{{ key }}
|
||||||
</td>
|
</td>
|
||||||
<td v-else>{{ value }}</td>
|
<td
|
||||||
</tr>
|
v-if="link"
|
||||||
</tbody>
|
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap dark:text-white"
|
||||||
</table>
|
>
|
||||||
<div class="buttons">
|
<router-link :to="link">
|
||||||
<b-button
|
{{ value }}
|
||||||
@click="suspendProfile"
|
</router-link>
|
||||||
v-if="person.domain && !person.suspended"
|
</td>
|
||||||
type="is-primary"
|
<td
|
||||||
>{{ $t("Suspend") }}</b-button
|
v-else
|
||||||
|
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap dark:text-white"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="mt-4 mb-3">
|
||||||
|
<h2 class="text-lg font-bold">{{ $t("Actions") }}</h2>
|
||||||
|
<div class="buttons" v-if="person.domain">
|
||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
role="alert"
|
||||||
>
|
>
|
||||||
<b-button
|
<i18n
|
||||||
@click="unsuspendProfile"
|
path="This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it."
|
||||||
v-if="person.domain && person.suspended"
|
>
|
||||||
type="is-primary"
|
<template #access_the_corresponding_account>
|
||||||
>{{ $t("Unsuspend") }}</b-button
|
<router-link
|
||||||
>
|
class="underline"
|
||||||
</div>
|
:to="{
|
||||||
<section>
|
name: RouteName.ADMIN_USER_PROFILE,
|
||||||
<h2 class="subtitle">
|
params: { id: person.user.id },
|
||||||
{{
|
}"
|
||||||
$tc("{number} organized events", person.organizedEvents.total, {
|
>{{ $t("access the corresponding account") }}</router-link
|
||||||
number: person.organizedEvents.total,
|
>
|
||||||
})
|
</template>
|
||||||
}}
|
</i18n>
|
||||||
</h2>
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="mt-4 mb-3">
|
||||||
|
<h2 class="text-lg font-bold">{{ $t("Organized events") }}</h2>
|
||||||
<b-table
|
<b-table
|
||||||
:data="person.organizedEvents.elements"
|
:data="person.organizedEvents.elements"
|
||||||
:loading="$apollo.queries.person.loading"
|
:loading="$apollo.queries.person.loading"
|
||||||
@ -93,14 +128,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</b-table>
|
</b-table>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section class="mt-4 mb-3">
|
||||||
<h2 class="subtitle">
|
<h2 class="text-lg font-bold">{{ $t("Participations") }}</h2>
|
||||||
{{
|
|
||||||
$tc("{number} participations", person.participations.total, {
|
|
||||||
number: person.participations.total,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
<b-table
|
<b-table
|
||||||
:data="
|
:data="
|
||||||
person.participations.elements.map(
|
person.participations.elements.map(
|
||||||
@ -140,14 +169,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</b-table>
|
</b-table>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section class="mt-4 mb-3">
|
||||||
<h2 class="subtitle">
|
<h2 class="text-lg font-bold">{{ $t("Memberships") }}</h2>
|
||||||
{{
|
|
||||||
$tc("{number} memberships", person.memberships.total, {
|
|
||||||
number: person.memberships.total,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
<b-table
|
<b-table
|
||||||
:data="person.memberships.elements"
|
:data="person.memberships.elements"
|
||||||
:loading="$apollo.loading"
|
:loading="$apollo.loading"
|
||||||
@ -512,16 +535,3 @@ export default class AdminProfile extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
table,
|
|
||||||
section {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actor-card {
|
|
||||||
background: #fff;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -15,48 +15,113 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<table v-if="metadata.length > 0" class="table is-fullwidth">
|
<section>
|
||||||
<tbody>
|
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
|
||||||
<tr v-for="{ key, value, link, elements, type } in metadata" :key="key">
|
<div class="flex flex-col">
|
||||||
<td>{{ key }}</td>
|
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
<td v-if="elements && elements.length > 0">
|
<div class="inline-block py-2 min-w-full sm:px-2 lg:px-8">
|
||||||
<ul
|
<div class="overflow-hidden shadow-md sm:rounded-lg">
|
||||||
v-for="{ value, link: elementLink, active } in elements"
|
<table v-if="metadata.length > 0" class="min-w-full">
|
||||||
:key="value"
|
<tbody>
|
||||||
>
|
<tr
|
||||||
<li>
|
class="odd:bg-white even:bg-gray-50 border-b odd:dark:bg-gray-800 even:dark:bg-gray-700 dark:border-gray-600"
|
||||||
<router-link :to="elementLink">
|
v-for="{ key, value, link, elements, type } in metadata"
|
||||||
<span v-if="active">{{
|
:key="key"
|
||||||
$t("{profile} (by default)", { profile: value })
|
>
|
||||||
}}</span>
|
<td class="py-4 px-2 whitespace-nowrap dark:text-white">
|
||||||
<span v-else>{{ value }}</span>
|
{{ key }}
|
||||||
</router-link>
|
</td>
|
||||||
</li>
|
<td
|
||||||
</ul>
|
v-if="elements && elements.length > 0"
|
||||||
</td>
|
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap dark:text-white"
|
||||||
<td v-else-if="elements">
|
>
|
||||||
{{ $t("None") }}
|
<ul
|
||||||
</td>
|
v-for="{ value, link: elementLink, active } in elements"
|
||||||
<td v-else-if="link">
|
:key="value"
|
||||||
<router-link :to="link">
|
>
|
||||||
{{ value }}
|
<li>
|
||||||
</router-link>
|
<router-link :to="elementLink">
|
||||||
</td>
|
<span v-if="active">{{
|
||||||
<td v-else-if="type == 'code'">
|
$t("{profile} (by default)", { profile: value })
|
||||||
<code>{{ value }}</code>
|
}}</span>
|
||||||
</td>
|
<span v-else>{{ value }}</span>
|
||||||
<td v-else>{{ value }}</td>
|
</router-link>
|
||||||
</tr>
|
</li>
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
</td>
|
||||||
<div class="buttons">
|
<td
|
||||||
<b-button
|
v-else-if="elements"
|
||||||
@click="deleteAccount"
|
class="py-4 px-2 whitespace-nowrap dark:text-white"
|
||||||
v-if="!user.disabled"
|
>
|
||||||
type="is-primary"
|
{{ $t("None") }}
|
||||||
>{{ $t("Suspend") }}</b-button
|
</td>
|
||||||
>
|
<td
|
||||||
</div>
|
v-else-if="link"
|
||||||
|
class="py-4 px-2 whitespace-nowrap dark:text-white"
|
||||||
|
>
|
||||||
|
<router-link :to="link">
|
||||||
|
{{ value }}
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else-if="type === 'code'"
|
||||||
|
class="py-4 px-2 whitespace-nowrap dark:text-white"
|
||||||
|
>
|
||||||
|
<code>{{ value }}</code>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else-if="type === 'badge'"
|
||||||
|
class="py-4 px-2 whitespace-nowrap dark:text-white"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="py-4 px-2 whitespace-nowrap dark:text-white"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<empty-content v-else-if="!$apollo.loading" icon="account">
|
<empty-content v-else-if="!$apollo.loading" icon="account">
|
||||||
{{ $t("This user was not found") }}
|
{{ $t("This user was not found") }}
|
||||||
@ -76,11 +141,12 @@ import { Route } from "vue-router";
|
|||||||
import { formatBytes } from "@/utils/datetime";
|
import { formatBytes } from "@/utils/datetime";
|
||||||
import { ICurrentUserRole } from "@/types/enums";
|
import { ICurrentUserRole } from "@/types/enums";
|
||||||
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
|
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
|
||||||
import { usernameWithDomain } from "../../types/actor/actor.model";
|
import { IActor, usernameWithDomain } from "../../types/actor/actor.model";
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import { IUser } from "../../types/current-user.model";
|
import { IUser } from "../../types/current-user.model";
|
||||||
import { IPerson } from "../../types/actor";
|
|
||||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||||
|
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||||
|
import { LANGUAGES_CODES } from "@/graphql/admin";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
apollo: {
|
apollo: {
|
||||||
@ -96,6 +162,17 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
|||||||
return !this.id;
|
return !this.id;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
languages: {
|
||||||
|
query: LANGUAGES_CODES,
|
||||||
|
variables() {
|
||||||
|
return {
|
||||||
|
codes: [this.languageCode],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
skip() {
|
||||||
|
return !this.languageCode;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
metaInfo() {
|
metaInfo() {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@ -107,6 +184,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
|
ActorCard,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class AdminUserProfile extends Vue {
|
export default class AdminUserProfile extends Vue {
|
||||||
@ -114,6 +192,8 @@ export default class AdminUserProfile extends Vue {
|
|||||||
|
|
||||||
user!: IUser;
|
user!: IUser;
|
||||||
|
|
||||||
|
languages!: Array<{ code: string; name: string }>;
|
||||||
|
|
||||||
usernameWithDomain = usernameWithDomain;
|
usernameWithDomain = usernameWithDomain;
|
||||||
|
|
||||||
RouteName = RouteName;
|
RouteName = RouteName;
|
||||||
@ -127,11 +207,14 @@ export default class AdminUserProfile extends Vue {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: this.$i18n.t("Language"),
|
key: this.$i18n.t("Language"),
|
||||||
value: this.user.locale,
|
value: this.languages
|
||||||
|
? this.languages[0].name
|
||||||
|
: this.$i18n.t("Unknown"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: this.$i18n.t("Role"),
|
key: this.$i18n.t("Role"),
|
||||||
value: this.roleName(this.user.role),
|
value: this.roleName(this.user.role),
|
||||||
|
type: "badge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: this.$i18n.t("Login status"),
|
key: this.$i18n.t("Login status"),
|
||||||
@ -139,20 +222,6 @@ export default class AdminUserProfile extends Vue {
|
|||||||
? this.$i18n.t("Disabled")
|
? this.$i18n.t("Disabled")
|
||||||
: this.$t("Activated"),
|
: 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"),
|
key: this.$i18n.t("Confirmed"),
|
||||||
value:
|
value:
|
||||||
@ -175,12 +244,16 @@ export default class AdminUserProfile extends Vue {
|
|||||||
type: "code",
|
type: "code",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: this.$i18n.t("Participations"),
|
key: this.$i18n.t("Total number of participations"),
|
||||||
value: this.user.participations.total,
|
value: this.user.participations.total,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: this.$i18n.t("Uploaded media size"),
|
key: this.$i18n.t("Uploaded media total size"),
|
||||||
value: formatBytes(this.user.mediaSize),
|
value: formatBytes(
|
||||||
|
this.user.mediaSize,
|
||||||
|
2,
|
||||||
|
this.$i18n.t("0 Bytes") as string
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -206,11 +279,13 @@ export default class AdminUserProfile extends Vue {
|
|||||||
});
|
});
|
||||||
return this.$router.push({ name: RouteName.USERS });
|
return this.$router.push({ name: RouteName.USERS });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get profiles(): IActor[] {
|
||||||
|
return this.user.actors;
|
||||||
|
}
|
||||||
|
|
||||||
|
get languageCode(): string | undefined {
|
||||||
|
return this.user?.locale;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
table {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
paginated
|
paginated
|
||||||
backend-pagination
|
backend-pagination
|
||||||
backend-filtering
|
backend-filtering
|
||||||
detailed
|
|
||||||
:current-page.sync="page"
|
:current-page.sync="page"
|
||||||
:aria-next-label="$t('Next page')"
|
:aria-next-label="$t('Next page')"
|
||||||
:aria-previous-label="$t('Previous page')"
|
:aria-previous-label="$t('Previous page')"
|
||||||
@ -75,31 +74,6 @@
|
|||||||
>
|
>
|
||||||
{{ getLanguageNameForCode(props.row.locale) }}
|
{{ getLanguageNameForCode(props.row.locale) }}
|
||||||
</b-table-column>
|
</b-table-column>
|
||||||
|
|
||||||
<template #detail="props">
|
|
||||||
<router-link
|
|
||||||
class="profile"
|
|
||||||
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-32x32" v-if="actor.avatar">
|
|
||||||
<img :src="actor.avatar.url" />
|
|
||||||
</p>
|
|
||||||
<b-icon v-else size="is-medium" icon="account-circle" />
|
|
||||||
</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>
|
</b-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,8 +14,9 @@ module.exports = {
|
|||||||
colors: {
|
colors: {
|
||||||
primary: withOpacityValue("--color-primary"),
|
primary: withOpacityValue("--color-primary"),
|
||||||
secondary: withOpacityValue("--color-secondary"),
|
secondary: withOpacityValue("--color-secondary"),
|
||||||
|
"violet-title": withOpacityValue("--color-violet-title"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("@tailwindcss/line-clamp")],
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
|||||||
|
|
||||||
import Mobilizon.Users.Guards
|
import Mobilizon.Users.Guards
|
||||||
|
|
||||||
alias Mobilizon.{Actors, Admin, Config, Events, Instances}
|
alias Mobilizon.{Actors, Admin, Config, Events, Instances, Users}
|
||||||
alias Mobilizon.Actors.{Actor, Follower}
|
alias Mobilizon.Actors.{Actor, Follower}
|
||||||
alias Mobilizon.Admin.{ActionLog, Setting}
|
alias Mobilizon.Admin.{ActionLog, Setting}
|
||||||
alias Mobilizon.Cldr.Language
|
alias Mobilizon.Cldr.Language
|
||||||
@ -281,6 +281,86 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
|||||||
dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
|
dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_user(_parent, %{id: id, notify: notify} = args, %{
|
||||||
|
context: %{current_user: %User{role: role}}
|
||||||
|
})
|
||||||
|
when is_admin(role) do
|
||||||
|
case Users.get_user(id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :user_not_found}
|
||||||
|
|
||||||
|
%User{} = user ->
|
||||||
|
case args |> Map.drop([:notify, :id]) |> Map.keys() do
|
||||||
|
[] ->
|
||||||
|
{:error, :invalid_argument}
|
||||||
|
|
||||||
|
[change, _] ->
|
||||||
|
case change do
|
||||||
|
:email -> change_email(user, Map.get(args, :email), notify)
|
||||||
|
:role -> change_role(user, Map.get(args, :role), notify)
|
||||||
|
:confirmed -> confirm_user(user, Map.get(args, :confirmed), notify)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_user(_parent, _args, _resolution) do
|
||||||
|
{:error,
|
||||||
|
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())
|
||||||
|
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()
|
||||||
|
|
||||||
|
user
|
||||||
|
|> Email.User.send_email_reset_new_email()
|
||||||
|
|> Email.Mailer.send_email_later()
|
||||||
|
|
||||||
|
{:ok, user}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = err} ->
|
||||||
|
Logger.debug(inspect(err))
|
||||||
|
{:error, dgettext("errors", "Failed to update user email")}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, dgettext("errors", "The new email doesn't seem to be valid")}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, dgettext("errors", "The new email must be different")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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})
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
Users.update_user(user, %{confirmed_at: new_confirmed_at})
|
||||||
|
end
|
||||||
|
|
||||||
@spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) ::
|
@spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) ::
|
||||||
{:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated}
|
{:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated}
|
||||||
def list_relay_followers(
|
def list_relay_followers(
|
||||||
|
@ -409,5 +409,22 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
|||||||
|
|
||||||
resolve(&Admin.save_settings/3)
|
resolve(&Admin.save_settings/3)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@desc """
|
||||||
|
For an admin to update an user
|
||||||
|
"""
|
||||||
|
field :admin_update_user, type: :user do
|
||||||
|
arg(:id, non_null(:id), description: "The user's ID")
|
||||||
|
arg(:email, :string, description: "The user's new email")
|
||||||
|
arg(:confirmed, :string, description: "Manually confirm the user's account")
|
||||||
|
arg(:role, :user_role, description: "Set user's new role")
|
||||||
|
|
||||||
|
arg(:notify, :boolean,
|
||||||
|
default_value: false,
|
||||||
|
description: "Whether or not to notify the user of the change"
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve(&Admin.update_user/3)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Loading…
Reference in New Issue
Block a user