build: switch from yarn to npm to manage js dependencies and move js contents to root
yarn v1 is being deprecated and starts to have some issues Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
549
src/views/Admin/AdminGroupProfile.vue
Normal file
549
src/views/Admin/AdminGroupProfile.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div v-if="group" class="section">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
text: t('Groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: group.id },
|
||||
text: displayName(group),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div>
|
||||
<p v-if="group.suspended" class="mx-auto max-w-sm block mb-2">
|
||||
<actor-card
|
||||
:actor="group"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="false"
|
||||
/>
|
||||
</p>
|
||||
<router-link
|
||||
class="mx-auto max-w-sm block mb-2"
|
||||
v-else
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>
|
||||
<actor-card
|
||||
:actor="group"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="false"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<table v-if="metadata.length > 0" class="table w-full">
|
||||
<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="flex gap-1">
|
||||
<o-button
|
||||
@click="confirmSuspendProfile"
|
||||
v-if="!group.suspended"
|
||||
variant="primary"
|
||||
>{{ t("Suspend") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="
|
||||
unsuspendProfile({
|
||||
id,
|
||||
})
|
||||
"
|
||||
v-if="group.suspended"
|
||||
variant="primary"
|
||||
>{{ t("Unsuspend") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="
|
||||
refreshProfile({
|
||||
actorId: id,
|
||||
})
|
||||
"
|
||||
v-if="group.domain"
|
||||
variant="primary"
|
||||
outlined
|
||||
>{{ t("Refresh profile") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<section>
|
||||
<h2>
|
||||
{{
|
||||
t(
|
||||
"{number} members",
|
||||
{
|
||||
number: group.members.total,
|
||||
},
|
||||
group.members.total
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<o-table
|
||||
:data="group.members.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="membersPage"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
:total="group.members.total"
|
||||
:per-page="MEMBERS_PER_PAGE"
|
||||
@page-change="onMembersPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="t('Member')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-1">
|
||||
<div class="flex-none">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
>
|
||||
<figure v-if="props.row.actor.avatar">
|
||||
<img
|
||||
class="rounded"
|
||||
:src="props.row.actor.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle :size="48" v-else />
|
||||
</router-link>
|
||||
</div>
|
||||
<div>
|
||||
<div class="prose dark:prose-invert">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
v-if="props.row.actor.name"
|
||||
>{{ props.row.actor.name }}</router-link
|
||||
><router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
v-else
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</router-link
|
||||
><br />
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
v-if="props.row.actor.name"
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</router-link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="primary"
|
||||
v-if="props.row.role === MemberRole.ADMINISTRATOR"
|
||||
>
|
||||
{{ t("Administrator") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="primary"
|
||||
v-else-if="props.row.role === MemberRole.MODERATOR"
|
||||
>
|
||||
{{ t("Moderator") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === MemberRole.MEMBER">
|
||||
{{ t("Member") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
|
||||
>
|
||||
{{ t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.REJECTED"
|
||||
>
|
||||
{{ t("Rejected") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.INVITED"
|
||||
>
|
||||
{{ t("Invited") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ t("No members found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>
|
||||
{{
|
||||
t(
|
||||
"{number} organized events",
|
||||
{
|
||||
number: group.organizedEvents.total,
|
||||
},
|
||||
group.organizedEvents.total
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<o-table
|
||||
:data="group.organizedEvents.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="organizedEventsPage"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
:total="group.organizedEvents.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onOrganizedEventsPageChange"
|
||||
>
|
||||
<o-table-column field="title" :label="t('Title')" v-slot="props">
|
||||
<router-link
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
|
||||
>
|
||||
{{ props.row.title }}
|
||||
<tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
|
||||
</router-link>
|
||||
</o-table-column>
|
||||
<o-table-column field="beginsOn" :label="t('Begins on')" v-slot="props">
|
||||
{{ formatDateTimeString(props.row.beginsOn) }}
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ t("No organized events found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>
|
||||
{{
|
||||
t(
|
||||
"{number} posts",
|
||||
{
|
||||
number: group.posts.total,
|
||||
},
|
||||
group.posts.total
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<o-table
|
||||
:data="group.posts.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="postsPage"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
:total="group.posts.total"
|
||||
:per-page="POSTS_PER_PAGE"
|
||||
@page-change="onPostsPageChange"
|
||||
>
|
||||
<o-table-column field="title" :label="t('Title')" v-slot="props">
|
||||
<router-link
|
||||
:to="{ name: RouteName.POST, params: { slug: props.row.slug } }"
|
||||
>
|
||||
{{ props.row.title }}
|
||||
<tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
|
||||
</router-link>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="publishAt"
|
||||
:label="t('Publication date')"
|
||||
v-slot="props"
|
||||
>
|
||||
{{ formatDateTimeString(props.row.publishAt) }}
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="bullhorn" :inline="true">
|
||||
{{ t("No posts found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
</div>
|
||||
<empty-content v-else-if="!loading" icon="account-multiple">
|
||||
{{ t("This group was not found") }}
|
||||
<template #desc>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.ADMIN_GROUPS }"
|
||||
>{{ t("Back to group list") }}</o-button
|
||||
>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
|
||||
import { IGroup } from "../../types/actor";
|
||||
import {
|
||||
usernameWithDomain,
|
||||
displayName,
|
||||
IActor,
|
||||
} from "../../types/actor/actor.model";
|
||||
import RouteName from "../../router/name";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import { ApolloCache, FetchResult } from "@apollo/client/core";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, inject } from "vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
formatTimeString,
|
||||
formatDateString,
|
||||
formatDateTimeString,
|
||||
} from "@/filters/datetime";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
const EVENTS_PER_PAGE = 10;
|
||||
const POSTS_PER_PAGE = 10;
|
||||
const MEMBERS_PER_PAGE = 10;
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const organizedEventsPage = useRouteQuery(
|
||||
"organizedEventsPage",
|
||||
1,
|
||||
integerTransformer
|
||||
);
|
||||
const membersPage = useRouteQuery("membersPage", 1, integerTransformer);
|
||||
const postsPage = useRouteQuery("postsPage", 1, integerTransformer);
|
||||
|
||||
const {
|
||||
result: groupResult,
|
||||
loading,
|
||||
fetchMore,
|
||||
} = useQuery(
|
||||
GET_GROUP,
|
||||
() => ({
|
||||
id: props.id,
|
||||
organizedEventsPage: organizedEventsPage.value,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
postsPage: postsPage.value,
|
||||
postsLimit: POSTS_PER_PAGE,
|
||||
membersLimit: MEMBERS_PER_PAGE,
|
||||
membersPage: membersPage.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled: props.id !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const group = computed(() => groupResult.value?.getGroup);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => displayName(group.value)),
|
||||
});
|
||||
|
||||
const metadata = computed((): Array<Record<string, string>> => {
|
||||
if (!group.value) return [];
|
||||
const res: Record<string, string>[] = [
|
||||
{
|
||||
key: t("Status") as string,
|
||||
value: (group.value.suspended ? t("Suspended") : t("Active")) as string,
|
||||
},
|
||||
{
|
||||
key: t("Domain") as string,
|
||||
value: (group.value.domain ? group.value.domain : t("Local")) as string,
|
||||
},
|
||||
{
|
||||
key: t("Uploaded media size") as string,
|
||||
value: formatBytes(group.value.mediaSize),
|
||||
},
|
||||
];
|
||||
return res;
|
||||
});
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const confirmSuspendProfile = (): void => {
|
||||
const message = group.value.domain
|
||||
? t(
|
||||
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
|
||||
{ instance: group.value.domain }
|
||||
)
|
||||
: t(
|
||||
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
|
||||
);
|
||||
|
||||
dialog?.confirm({
|
||||
title: t("Suspend group"),
|
||||
message,
|
||||
confirmText: t("Suspend group"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
hasIcon: true,
|
||||
onConfirm: () =>
|
||||
suspendProfile({
|
||||
id: props.id,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: suspendProfile, onError: onSuspendProfileError } = useMutation<{
|
||||
suspendProfile: { id: string };
|
||||
}>(SUSPEND_PROFILE, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{ suspendProfile: { id: string } }>,
|
||||
{ data }: FetchResult
|
||||
) => {
|
||||
if (data == null) return;
|
||||
const profileId = props.id;
|
||||
|
||||
const profileData = store.readQuery<{ getGroup: IGroup }>({
|
||||
query: GET_GROUP,
|
||||
variables: {
|
||||
id: profileId,
|
||||
organizedEventsPage: organizedEventsPage.value,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
postsPage: postsPage.value,
|
||||
postsLimit: POSTS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!profileData) return;
|
||||
store.writeQuery({
|
||||
query: GET_GROUP,
|
||||
variables: {
|
||||
id: profileId,
|
||||
},
|
||||
data: {
|
||||
getGroup: {
|
||||
...profileData.getGroup,
|
||||
suspended: true,
|
||||
avatar: null,
|
||||
name: "",
|
||||
summary: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
onSuspendProfileError((e) => {
|
||||
console.error(e);
|
||||
notifier?.error(t("Error while suspending group"));
|
||||
});
|
||||
|
||||
const { mutate: unsuspendProfile, onError: onUnsuspendProfileError } =
|
||||
useMutation(UNSUSPEND_PROFILE, () => ({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GET_GROUP,
|
||||
variables: {
|
||||
id: props.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onUnsuspendProfileError((e) => {
|
||||
console.error(e);
|
||||
notifier?.error(t("Error while suspending group"));
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: refreshProfile,
|
||||
onDone: onRefreshProfileDone,
|
||||
onError: onRefreshProfileError,
|
||||
} = useMutation<{ refreshProfile: IActor }>(REFRESH_PROFILE);
|
||||
|
||||
onRefreshProfileDone(() => {
|
||||
notifier?.success(t("Triggered profile refreshment"));
|
||||
});
|
||||
|
||||
onRefreshProfileError((e) => {
|
||||
console.error(e);
|
||||
notifier?.error(t("Error while suspending group"));
|
||||
});
|
||||
|
||||
const onOrganizedEventsPageChange = async (page: number): Promise<void> => {
|
||||
organizedEventsPage.value = page;
|
||||
await fetchMore({
|
||||
variables: {
|
||||
id: props.id,
|
||||
organizedEventsPage: organizedEventsPage.value,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onMembersPageChange = async (page: number): Promise<void> => {
|
||||
membersPage.value = page;
|
||||
await fetchMore({
|
||||
variables: {
|
||||
id: props.id,
|
||||
membersPage: membersPage.value,
|
||||
membersLimit: EVENTS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onPostsPageChange = async (page: number): Promise<void> => {
|
||||
postsPage.value = page;
|
||||
await fetchMore({
|
||||
variables: {
|
||||
id: props.id,
|
||||
postsPage: postsPage.value,
|
||||
postsLimit: POSTS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
510
src/views/Admin/AdminProfile.vue
Normal file
510
src/views/Admin/AdminProfile.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div v-if="person" class="section">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
text: $t('Profiles'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: person.id },
|
||||
text: displayName(person),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<actor-card
|
||||
:actor="person"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="false"
|
||||
/>
|
||||
</div>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $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-hidden shadow-md sm:rounded-lg">
|
||||
<table v-if="metadata.length > 0" class="min-w-full">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="{ key, value, link } in metadata"
|
||||
:key="key"
|
||||
class="odd:bg-white dark:odd:bg-zinc-800 even:bg-gray-50 dark:even:bg-zinc-700 border-b"
|
||||
>
|
||||
<td class="py-4 px-2 whitespace-nowrap">
|
||||
{{ key }}
|
||||
</td>
|
||||
<td
|
||||
v-if="link"
|
||||
class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
{{ value }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Actions") }}</h2>
|
||||
<div class="buttons" v-if="person.domain">
|
||||
<o-button
|
||||
@click="suspendProfile({ id })"
|
||||
v-if="person.domain && !person.suspended"
|
||||
variant="primary"
|
||||
>{{ $t("Suspend") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="unsuspendProfile({ id })"
|
||||
v-if="person.domain && person.suspended"
|
||||
variant="primary"
|
||||
>{{ $t("Unsuspend") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<p v-else></p>
|
||||
<div
|
||||
v-if="person.user"
|
||||
class="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg"
|
||||
role="alert"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it."
|
||||
>
|
||||
<template #access_the_corresponding_account>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: person.user.id },
|
||||
}"
|
||||
>{{ $t("access the corresponding account") }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Organized events") }}</h2>
|
||||
<o-table
|
||||
:data="person.organizedEvents?.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="organizedEventsPage"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="person.organizedEvents?.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onOrganizedEventsPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="beginsOn"
|
||||
:label="$t('Begins on')"
|
||||
v-slot="props"
|
||||
>
|
||||
{{ formatDateTimeString(props.row.beginsOn) }}
|
||||
</o-table-column>
|
||||
<o-table-column field="title" :label="$t('Title')" v-slot="props">
|
||||
<router-link
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
|
||||
>
|
||||
{{ props.row.title }}
|
||||
</router-link>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ $t("No organized events listed") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Participations") }}</h2>
|
||||
<o-table
|
||||
:data="
|
||||
person.participations?.elements.map(
|
||||
(participation) => participation.event
|
||||
)
|
||||
"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="participationsPage"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="person.participations?.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onParticipationsPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="beginsOn"
|
||||
:label="$t('Begins on')"
|
||||
v-slot="props"
|
||||
>
|
||||
{{ formatDateTimeString(props.row.beginsOn) }}
|
||||
</o-table-column>
|
||||
<o-table-column field="title" :label="$t('Title')" v-slot="props">
|
||||
<router-link
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
|
||||
>
|
||||
{{ props.row.title }}
|
||||
</router-link>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ $t("No participations listed") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Memberships") }}</h2>
|
||||
<o-table
|
||||
:data="person.memberships?.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="membershipsPage"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="person.memberships?.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onMembershipsPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="parent.preferredUsername"
|
||||
:label="$t('Group')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-2">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.parent.id },
|
||||
}"
|
||||
>
|
||||
<figure class="" v-if="props.row.parent.avatar">
|
||||
<img
|
||||
class="rounded-full"
|
||||
:src="props.row.parent.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</router-link>
|
||||
<div class="">
|
||||
<div class="prose dark:prose-invert">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.parent.id },
|
||||
}"
|
||||
v-if="props.row.parent.name"
|
||||
>{{ props.row.parent.name }}</router-link
|
||||
><br />
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.parent.id },
|
||||
}"
|
||||
>@{{ usernameWithDomain(props.row.parent) }}</router-link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="$t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="primary"
|
||||
v-if="props.row.role === MemberRole.ADMINISTRATOR"
|
||||
>
|
||||
{{ $t("Administrator") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="primary"
|
||||
v-else-if="props.row.role === MemberRole.MODERATOR"
|
||||
>
|
||||
{{ $t("Moderator") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === MemberRole.MEMBER">
|
||||
{{ $t("Member") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
|
||||
>
|
||||
{{ $t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.REJECTED"
|
||||
>
|
||||
{{ $t("Rejected") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.INVITED"
|
||||
>
|
||||
{{ $t("Invited") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ $t("No memberships found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
</div>
|
||||
<empty-content v-else-if="!loading" icon="account">
|
||||
{{ $t("This profile was not found") }}
|
||||
<template #desc>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.PROFILES }"
|
||||
>{{ $t("Back to profile list") }}</o-button
|
||||
>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import {
|
||||
GET_PERSON,
|
||||
SUSPEND_PROFILE,
|
||||
UNSUSPEND_PROFILE,
|
||||
} from "@/graphql/actor";
|
||||
import { IPerson } from "@/types/actor";
|
||||
import { displayName, usernameWithDomain } from "@/types/actor/actor.model";
|
||||
import RouteName from "@/router/name";
|
||||
import ActorCard from "@/components/Account/ActorCard.vue";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import { ApolloCache, FetchResult } from "@apollo/client/core";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
formatDateString,
|
||||
formatTimeString,
|
||||
formatDateTimeString,
|
||||
} from "@/filters/datetime";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
const EVENTS_PER_PAGE = 10;
|
||||
const PARTICIPATIONS_PER_PAGE = 10;
|
||||
const MEMBERSHIPS_PER_PAGE = 10;
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const organizedEventsPage = useRouteQuery(
|
||||
"organizedEventsPage",
|
||||
1,
|
||||
integerTransformer
|
||||
);
|
||||
const participationsPage = useRouteQuery(
|
||||
"participationsPage",
|
||||
1,
|
||||
integerTransformer
|
||||
);
|
||||
const membershipsPage = useRouteQuery("membershipsPage", 1, integerTransformer);
|
||||
|
||||
const {
|
||||
result: personResult,
|
||||
fetchMore,
|
||||
loading,
|
||||
} = useQuery<{ person: IPerson }>(GET_PERSON, () => ({
|
||||
actorId: props.id,
|
||||
organizedEventsPage: organizedEventsPage.value,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
participationsPage: participationsPage.value,
|
||||
participationLimit: PARTICIPATIONS_PER_PAGE,
|
||||
membershipsPage: membershipsPage.value,
|
||||
membershipsLimit: MEMBERSHIPS_PER_PAGE,
|
||||
}));
|
||||
|
||||
const person = computed(() => personResult.value?.person);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => displayName(person.value)),
|
||||
});
|
||||
|
||||
const metadata = computed(
|
||||
(): Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
link?: { name: string; params: Record<string, any> };
|
||||
}> => {
|
||||
if (!person.value) return [];
|
||||
const res: {
|
||||
key: string;
|
||||
value: string;
|
||||
link?: { name: string; params: Record<string, any> };
|
||||
}[] = [
|
||||
{
|
||||
key: t("Status"),
|
||||
value: person.value.suspended ? t("Suspended") : t("Active"),
|
||||
},
|
||||
{
|
||||
key: t("Domain"),
|
||||
value: person.value.domain ? person.value.domain : t("Local"),
|
||||
link: person.value.domain
|
||||
? {
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: person.value.domain },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: t("Uploaded media size"),
|
||||
value: formatBytes(person.value.mediaSize ?? 0),
|
||||
},
|
||||
];
|
||||
if (!person.value.domain && person.value.user) {
|
||||
res.push({
|
||||
key: t("User"),
|
||||
link: {
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: person.value.user.id },
|
||||
},
|
||||
value: person.value.user.email,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: suspendProfile } = useMutation<
|
||||
{
|
||||
suspendProfile: { id: string };
|
||||
},
|
||||
{ id: string }
|
||||
>(SUSPEND_PROFILE, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{ suspendProfile: { id: string } }>,
|
||||
{ data }: FetchResult
|
||||
) => {
|
||||
if (data == null) return;
|
||||
const profileId = props.id;
|
||||
|
||||
const profileData = store.readQuery<{ person: IPerson }>({
|
||||
query: GET_PERSON,
|
||||
variables: {
|
||||
actorId: profileId,
|
||||
organizedEventsPage: 1,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
participationsPage: 1,
|
||||
participationLimit: PARTICIPATIONS_PER_PAGE,
|
||||
membershipsPage: 1,
|
||||
membershipsLimit: MEMBERSHIPS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!profileData) return;
|
||||
const { person: cachedPerson } = profileData;
|
||||
store.writeQuery({
|
||||
query: GET_PERSON,
|
||||
variables: {
|
||||
actorId: profileId,
|
||||
},
|
||||
data: {
|
||||
person: {
|
||||
...cloneDeep(cachedPerson),
|
||||
participations: { total: 0, elements: [] },
|
||||
suspended: true,
|
||||
avatar: null,
|
||||
name: "",
|
||||
summary: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const { mutate: unsuspendProfile } = useMutation<
|
||||
{ unsuspendProfile: { id: string } },
|
||||
{ id: string }
|
||||
>(UNSUSPEND_PROFILE, () => ({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GET_PERSON,
|
||||
variables: {
|
||||
actorId: props.id,
|
||||
organizedEventsPage: 1,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const onOrganizedEventsPageChange = async (): Promise<void> => {
|
||||
await fetchMore({
|
||||
variables: {
|
||||
actorId: props.id,
|
||||
organizedEventsPage: organizedEventsPage.value,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onParticipationsPageChange = async (): Promise<void> => {
|
||||
await fetchMore({
|
||||
variables: {
|
||||
actorId: props.id,
|
||||
participationPage: participationsPage.value,
|
||||
participationLimit: PARTICIPATIONS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onMembershipsPageChange = async (): Promise<void> => {
|
||||
await fetchMore({
|
||||
variables: {
|
||||
actorId: props.id,
|
||||
membershipsPage: participationsPage.value,
|
||||
membershipsLimit: MEMBERSHIPS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
524
src/views/Admin/AdminUserProfile.vue
Normal file
524
src/views/Admin/AdminUserProfile.vue
Normal file
@@ -0,0 +1,524 @@
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{
|
||||
name: RouteName.USERS,
|
||||
text: t('Users'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: user.id },
|
||||
text: user.email,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-bold mb-3">{{ t("Details") }}</h2>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<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="table w-full">
|
||||
<tbody>
|
||||
<tr
|
||||
class="border-b"
|
||||
v-for="{ key, value, type } in metadata"
|
||||
:key="key"
|
||||
>
|
||||
<td class="py-4 px-2 whitespace-nowrap align-middle">
|
||||
{{ key }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-if="type === 'ip'"
|
||||
class="py-4 px-2 whitespace-nowrap"
|
||||
>
|
||||
<code class="truncate block max-w-[15rem]">{{
|
||||
value
|
||||
}}</code>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'role'"
|
||||
class="py-4 px-2 whitespace-nowrap"
|
||||
>
|
||||
<span
|
||||
: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 align-middle">
|
||||
{{ value }}
|
||||
</td>
|
||||
<td
|
||||
v-if="type === 'email'"
|
||||
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start gap-2"
|
||||
>
|
||||
<o-button
|
||||
size="small"
|
||||
v-if="!user.disabled"
|
||||
@click="isEmailChangeModalActive = true"
|
||||
variant="text"
|
||||
icon-left="pencil"
|
||||
>{{ t("Change email") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
query: { emailFilter: `@${userEmailDomain}` },
|
||||
}"
|
||||
size="small"
|
||||
variant="text"
|
||||
icon-left="magnify"
|
||||
>{{
|
||||
t("Other users with the same email domain")
|
||||
}}</o-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'confirmed'"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<o-button
|
||||
size="small"
|
||||
v-if="!user.confirmedAt || user.disabled"
|
||||
@click="isConfirmationModalActive = true"
|
||||
variant="text"
|
||||
icon-left="check"
|
||||
>{{ t("Confirm user") }}</o-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'role'"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<o-button
|
||||
size="small"
|
||||
v-if="!user.disabled"
|
||||
@click="isRoleChangeModalActive = true"
|
||||
variant="text"
|
||||
icon-left="chevron-double-up"
|
||||
>{{ t("Change role") }}</o-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'ip' && user.currentSignInIp"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
query: { ipFilter: user.currentSignInIp },
|
||||
}"
|
||||
size="small"
|
||||
variant="text"
|
||||
icon-left="web"
|
||||
>{{
|
||||
t("Other users with the same IP address")
|
||||
}}</o-button
|
||||
>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="my-4">
|
||||
<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 && profiles.length > 0"
|
||||
>
|
||||
<router-link
|
||||
v-for="profile in profiles"
|
||||
:key="profile.id"
|
||||
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: profile.id } }"
|
||||
>
|
||||
<actor-card
|
||||
:actor="profile"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="true"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<empty-content v-else-if="!loadingUser" :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">
|
||||
<o-button @click="suspendAccount" variant="danger">{{
|
||||
t("Suspend")
|
||||
}}</o-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>
|
||||
<o-modal
|
||||
v-model:active="isEmailChangeModalActive"
|
||||
trap-focus
|
||||
:destroy-on-hide="false"
|
||||
aria-role="dialog"
|
||||
:aria-label="t('Edit user email')"
|
||||
:close-button-aria-label="t('Close')"
|
||||
aria-modal
|
||||
>
|
||||
<form @submit.prevent="updateUserEmail">
|
||||
<div class="" style="width: auto">
|
||||
<header class="">
|
||||
<h2>{{ t("Change user email") }}</h2>
|
||||
</header>
|
||||
<section class="">
|
||||
<o-field :label="t('Previous email')">
|
||||
<o-input type="email" v-model="user.email" disabled />
|
||||
</o-field>
|
||||
<o-field :label="t('New email')">
|
||||
<o-input
|
||||
type="email"
|
||||
v-model="newUser.email"
|
||||
:placeholder="t(`new{'@'}email.com`)"
|
||||
required
|
||||
>
|
||||
</o-input>
|
||||
</o-field>
|
||||
<o-checkbox v-model="newUser.notify">{{
|
||||
t("Notify the user of the change")
|
||||
}}</o-checkbox>
|
||||
</section>
|
||||
<footer class="mt-2 flex gap-2">
|
||||
<o-button outlined @click="isEmailChangeModalActive = false">{{
|
||||
t("Close")
|
||||
}}</o-button>
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Change email")
|
||||
}}</o-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model: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
|
||||
>
|
||||
<form @submit.prevent="updateUserRole">
|
||||
<header>
|
||||
<h2>{{ t("Change user role") }}</h2>
|
||||
</header>
|
||||
<section>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="newUser.role"
|
||||
:native-value="ICurrentUserRole.ADMINISTRATOR"
|
||||
>
|
||||
{{ t("Administrator") }}
|
||||
</o-radio>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="newUser.role"
|
||||
:native-value="ICurrentUserRole.MODERATOR"
|
||||
>
|
||||
{{ t("Moderator") }}
|
||||
</o-radio>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="newUser.role"
|
||||
:native-value="ICurrentUserRole.USER"
|
||||
>
|
||||
{{ t("User") }}
|
||||
</o-radio>
|
||||
</o-field>
|
||||
<o-checkbox v-model="newUser.notify">{{
|
||||
t("Notify the user of the change")
|
||||
}}</o-checkbox>
|
||||
</section>
|
||||
<footer class="mt-2 flex gap-2">
|
||||
<o-button @click="isRoleChangeModalActive = false" outlined>{{
|
||||
t("Close")
|
||||
}}</o-button>
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Change role")
|
||||
}}</o-button>
|
||||
</footer>
|
||||
</form>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model: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
|
||||
>
|
||||
<form @submit.prevent="confirmUser">
|
||||
<header>
|
||||
<h2>{{ t("Confirm user") }}</h2>
|
||||
</header>
|
||||
<section>
|
||||
<o-checkbox v-model="newUser.notify">{{
|
||||
t("Notify the user of the change")
|
||||
}}</o-checkbox>
|
||||
</section>
|
||||
<footer>
|
||||
<o-button @click="isConfirmationModalActive = false">{{
|
||||
t("Close")
|
||||
}}</o-button>
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Confirm user")
|
||||
}}</o-button>
|
||||
</footer>
|
||||
</form>
|
||||
</o-modal>
|
||||
</div>
|
||||
<empty-content v-else-if="!loadingUser" icon="account">
|
||||
{{ t("This user was not found") }}
|
||||
<template #desc>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.USERS }"
|
||||
>{{ t("Back to user list") }}</o-button
|
||||
>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import { ICurrentUserRole } from "@/types/enums";
|
||||
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
|
||||
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 { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { ILanguage } from "@/types/admin.model";
|
||||
import { computed, inject, reactive, ref, watch } from "vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { formatDateTimeString } from "@/filters/datetime";
|
||||
import { useRouter } from "vue-router";
|
||||
import { IPerson } from "@/types/actor";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const { result: userResult, loading: loadingUser } = useQuery<{ user: IUser }>(
|
||||
GET_USER,
|
||||
() => ({
|
||||
id: props.id,
|
||||
})
|
||||
);
|
||||
|
||||
const user = computed(() => userResult.value?.user);
|
||||
|
||||
const languageCode = computed(() => user.value?.locale);
|
||||
|
||||
const { result: languagesResult } = useQuery<{ languages: ILanguage[] }>(
|
||||
LANGUAGES_CODES,
|
||||
() => ({
|
||||
codes: languageCode.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled: languageCode.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const languages = computed(() => languagesResult.value?.languages);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => user.value?.email ?? ""),
|
||||
});
|
||||
|
||||
const isEmailChangeModalActive = ref(false);
|
||||
const isRoleChangeModalActive = ref(false);
|
||||
const isConfirmationModalActive = ref(false);
|
||||
|
||||
const newUser = reactive({
|
||||
email: "",
|
||||
role: user.value?.role,
|
||||
confirm: false,
|
||||
notify: true,
|
||||
});
|
||||
|
||||
const metadata = computed(
|
||||
(): Array<{ key: string; value: string; type?: string }> => {
|
||||
if (!user.value) return [];
|
||||
return [
|
||||
{
|
||||
key: t("Email"),
|
||||
value: user.value.email,
|
||||
type: "email",
|
||||
},
|
||||
{
|
||||
key: t("Language"),
|
||||
value: languages.value ? languages.value[0].name : t("Unknown"),
|
||||
},
|
||||
{
|
||||
key: t("Role"),
|
||||
value: roleName(user.value.role),
|
||||
type: "role",
|
||||
},
|
||||
{
|
||||
key: t("Login status"),
|
||||
value: user.value.disabled ? t("Disabled") : t("Activated"),
|
||||
},
|
||||
{
|
||||
key: t("Confirmed"),
|
||||
value: user.value.confirmedAt
|
||||
? formatDateTimeString(user.value.confirmedAt)
|
||||
: t("Not confirmed"),
|
||||
type: "confirmed",
|
||||
},
|
||||
{
|
||||
key: t("Last sign-in"),
|
||||
value: user.value.currentSignInAt
|
||||
? formatDateTimeString(user.value.currentSignInAt)
|
||||
: t("Unknown"),
|
||||
},
|
||||
{
|
||||
key: t("Last IP adress"),
|
||||
value: user.value.currentSignInIp || t("Unknown"),
|
||||
type: user.value.currentSignInIp ? "ip" : undefined,
|
||||
},
|
||||
{
|
||||
key: t("Total number of participations"),
|
||||
value: user.value.participations.total.toString(),
|
||||
},
|
||||
{
|
||||
key: t("Uploaded media total size"),
|
||||
value: formatBytes(user.value.mediaSize),
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
const roleName = (role: ICurrentUserRole): string => {
|
||||
switch (role) {
|
||||
case ICurrentUserRole.ADMINISTRATOR:
|
||||
return t("Administrator");
|
||||
case ICurrentUserRole.MODERATOR:
|
||||
return t("Moderator");
|
||||
case ICurrentUserRole.USER:
|
||||
default:
|
||||
return t("User");
|
||||
}
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate: suspendUser } = useMutation<
|
||||
{ suspendProfile: { id: string } },
|
||||
{ userId: string }
|
||||
>(SUSPEND_USER);
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
|
||||
const suspendAccount = async (): Promise<void> => {
|
||||
dialog?.confirm({
|
||||
title: t("Suspend the account?"),
|
||||
message: t(
|
||||
"Do you really want to suspend this account? All of the user's profiles will be deleted."
|
||||
),
|
||||
confirmText: t("Suspend the account"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
onConfirm: async () => {
|
||||
suspendUser({
|
||||
userId: props.id,
|
||||
});
|
||||
return router.push({ name: RouteName.USERS });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const profiles = computed((): IPerson[] | undefined => {
|
||||
return user.value?.actors;
|
||||
});
|
||||
|
||||
const confirmUser = async () => {
|
||||
isConfirmationModalActive.value = false;
|
||||
await updateUser({
|
||||
id: props.id,
|
||||
confirmed: true,
|
||||
notify: newUser.notify,
|
||||
});
|
||||
};
|
||||
|
||||
const updateUserRole = async () => {
|
||||
isRoleChangeModalActive.value = false;
|
||||
await updateUser({
|
||||
id: props.id,
|
||||
role: newUser.role,
|
||||
notify: newUser.notify,
|
||||
});
|
||||
};
|
||||
|
||||
const updateUserEmail = async () => {
|
||||
isEmailChangeModalActive.value = false;
|
||||
await updateUser({
|
||||
id: props.id,
|
||||
email: newUser.email,
|
||||
notify: newUser.notify,
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateUser } = useMutation<
|
||||
{ adminUpdateUser: IUser },
|
||||
{
|
||||
id: string;
|
||||
email?: string;
|
||||
notify: boolean;
|
||||
confirmed?: boolean;
|
||||
role?: ICurrentUserRole;
|
||||
}
|
||||
>(ADMIN_UPDATE_USER);
|
||||
|
||||
watch(user, (updatedUser: IUser | undefined, oldUser: IUser | undefined) => {
|
||||
if (updatedUser?.role !== oldUser?.role) {
|
||||
newUser.role = updatedUser?.role;
|
||||
}
|
||||
});
|
||||
|
||||
const userEmailDomain = computed((): string | undefined => {
|
||||
if (user.value?.email) {
|
||||
return user.value?.email.split("@")[1];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
111
src/views/Admin/DashboardView.vue
Normal file
111
src/views/Admin/DashboardView.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{ text: t('Dashboard') },
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1>{{ t("Administration") }}</h1>
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-rows-2 lg:grid-flow-col gap-x-4 items-stretch"
|
||||
>
|
||||
<NumberDashboardTile :number="dashboard?.numberOfEvents">
|
||||
<template #subtitle>
|
||||
<i18n-t
|
||||
keypath="Published events with {comments} comments and {participations} confirmed participations"
|
||||
tag="p"
|
||||
>
|
||||
<template #comments>
|
||||
<b>{{ dashboard?.numberOfComments }}</b>
|
||||
</template>
|
||||
<template #participations>
|
||||
<b>{{
|
||||
dashboard?.numberOfConfirmedParticipationsToLocalEvents
|
||||
}}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</NumberDashboardTile>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfGroups"
|
||||
:subtitle="t('Groups', dashboard?.numberOfGroups ?? 0)"
|
||||
:to="{ name: RouteName.ADMIN_GROUPS }"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfUsers"
|
||||
:subtitle="t('Users', dashboard?.numberOfUsers ?? 0)"
|
||||
:to="{ name: RouteName.ADMIN_GROUPS }"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfReports"
|
||||
:subtitle="t('Opened reports', dashboard?.numberOfReports ?? 0)"
|
||||
:to="{ name: RouteName.REPORTS }"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfFollowers"
|
||||
:subtitle="
|
||||
t('Instances following you', dashboard?.numberOfFollowers ?? 0)
|
||||
"
|
||||
:to="{
|
||||
name: RouteName.INSTANCES,
|
||||
query: { followStatus: InstanceFilterFollowStatus.FOLLOWING },
|
||||
}"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfFollowings"
|
||||
:subtitle="
|
||||
t('Instances you follow', dashboard?.numberOfFollowings ?? 0)
|
||||
"
|
||||
:to="{
|
||||
name: RouteName.INSTANCES,
|
||||
query: { followStatus: InstanceFilterFollowStatus.FOLLOWED },
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<h2>{{ t("Last published event") }}</h2>
|
||||
<event-card
|
||||
v-if="dashboard?.lastPublicEventPublished"
|
||||
:event="dashboard?.lastPublicEventPublished"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ t("Last group created") }}</h2>
|
||||
<group-card
|
||||
v-if="dashboard?.lastGroupCreated"
|
||||
:group="dashboard?.lastGroupCreated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { DASHBOARD } from "@/graphql/admin";
|
||||
import { IDashboard } from "@/types/admin.model";
|
||||
import RouteName from "@/router/name";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import NumberDashboardTile from "@/components/Dashboard/NumberDashboardTile.vue";
|
||||
import LinkedNumberDashboardTile from "@/components/Dashboard/LinkedNumberDashboardTile.vue";
|
||||
import { InstanceFilterFollowStatus } from "@/types/enums";
|
||||
import GroupCard from "@/components/Group/GroupCard.vue";
|
||||
import EventCard from "@/components/Event/EventCard.vue";
|
||||
|
||||
const { result: dashboardResult } = useQuery<{ dashboard: IDashboard }>(
|
||||
DASHBOARD
|
||||
);
|
||||
|
||||
const dashboard = computed(() => dashboardResult.value?.dashboard);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Administration")),
|
||||
});
|
||||
</script>
|
||||
200
src/views/Admin/GroupProfiles.vue
Normal file
200
src/views/Admin/GroupProfiles.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: t('Moderation') },
|
||||
{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
text: t('Groups'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="buttons" v-if="showCreateGroupsButton">
|
||||
<router-link
|
||||
class="button is-primary"
|
||||
:to="{ name: RouteName.CREATE_GROUP }"
|
||||
>{{ t("Create group") }}</router-link
|
||||
>
|
||||
</div>
|
||||
<div v-if="groups">
|
||||
<div class="flex gap-2">
|
||||
<o-switch v-model="local">{{ t("Local") }}</o-switch>
|
||||
<o-switch v-model="suspended">{{ t("Suspended") }}</o-switch>
|
||||
</div>
|
||||
<o-table
|
||||
:data="groups.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
backend-filtering
|
||||
:debounce-search="500"
|
||||
v-model:current-page="page"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
:total="groups.total"
|
||||
:per-page="PROFILES_PER_PAGE"
|
||||
@page-change="onPageChange"
|
||||
@filters-change="onFiltersChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="preferredUsername"
|
||||
:label="t('Username')"
|
||||
searchable
|
||||
>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
:aria-label="t('Filter')"
|
||||
v-model="props.filters.preferredUsername"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
<router-link
|
||||
class="profile"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
}"
|
||||
>
|
||||
<article class="flex gap-1">
|
||||
<figure class="" v-if="props.row.avatar">
|
||||
<img
|
||||
:src="props.row.avatar.url"
|
||||
:alt="props.row.avatar.alt || ''"
|
||||
width="48"
|
||||
height="48"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</figure>
|
||||
<AccountGroup v-else :size="48" />
|
||||
<div class="">
|
||||
<div class="prose dark:prose-invert">
|
||||
<p v-if="props.row.name" class="font-bold mb-0">
|
||||
{{ props.row.name }}
|
||||
</p>
|
||||
<span class="text-sm"
|
||||
>@{{ props.row.preferredUsername }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</router-link>
|
||||
</template>
|
||||
</o-table-column>
|
||||
|
||||
<o-table-column field="domain" :label="t('Domain')" searchable>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
:aria-label="t('Filter')"
|
||||
v-model="props.filters.domain"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
{{ props.row.domain }}
|
||||
</template>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ t("No group matches the filters") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LIST_GROUPS } from "@/graphql/group";
|
||||
import RouteName from "../../router/name";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import { useRestrictions } from "@/composition/apollo/config";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
booleanTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } from "vue";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { IGroup } from "@/types/actor";
|
||||
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
|
||||
|
||||
const PROFILES_PER_PAGE = 10;
|
||||
|
||||
const { restrictions } = useRestrictions();
|
||||
|
||||
const preferredUsername = useRouteQuery("preferredUsername", "");
|
||||
const name = useRouteQuery("name", "");
|
||||
const domain = useRouteQuery("domain", "");
|
||||
|
||||
const local = useRouteQuery("local", domain.value === "", booleanTransformer);
|
||||
const suspended = useRouteQuery("suspended", false, booleanTransformer);
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
|
||||
const {
|
||||
result: groupsResult,
|
||||
fetchMore,
|
||||
loading,
|
||||
} = useQuery<{
|
||||
groups: Paginate<IGroup>;
|
||||
}>(LIST_GROUPS, () => ({
|
||||
preferredUsername: preferredUsername.value,
|
||||
name: name.value,
|
||||
domain: domain.value,
|
||||
local: local.value,
|
||||
suspended: suspended.value,
|
||||
page: page.value,
|
||||
limit: PROFILES_PER_PAGE,
|
||||
}));
|
||||
|
||||
const groups = computed(() => groupsResult.value?.groups);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({ title: computed(() => t("Groups")) });
|
||||
|
||||
const onPageChange = async (): Promise<void> => {
|
||||
await doFetchMore();
|
||||
};
|
||||
|
||||
const showCreateGroupsButton = computed((): boolean => {
|
||||
return !!restrictions.value?.onlyAdminCanCreateGroups;
|
||||
});
|
||||
|
||||
const onFiltersChange = ({
|
||||
preferredUsername: newPreferredUsername,
|
||||
domain: newDomain,
|
||||
}: {
|
||||
preferredUsername: string;
|
||||
domain: string;
|
||||
}): void => {
|
||||
preferredUsername.value = newPreferredUsername;
|
||||
domain.value = newDomain;
|
||||
doFetchMore();
|
||||
};
|
||||
|
||||
const doFetchMore = async (): Promise<void> => {
|
||||
await fetchMore({
|
||||
variables: {
|
||||
preferredUsername: preferredUsername.value,
|
||||
name: name.value,
|
||||
domain: domain.value,
|
||||
local: local.value,
|
||||
suspended: suspended.value,
|
||||
page: page.value,
|
||||
limit: PROFILES_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
a.profile {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
259
src/views/Admin/InstanceView.vue
Normal file
259
src/views/Admin/InstanceView.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div v-if="instance">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{ name: RouteName.INSTANCES, text: $t('Instances') },
|
||||
{ text: instance.domain },
|
||||
]"
|
||||
/>
|
||||
<h1 class="text-2xl">{{ instance.domain }}</h1>
|
||||
<div
|
||||
class="grid md:grid-cols-2 xl:grid-cols-4 gap-2 content-center text-center mt-2"
|
||||
>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.PROFILES,
|
||||
query: { domain: instance.domain },
|
||||
}"
|
||||
>
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.personCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Profiles") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
query: { domain: instance.domain },
|
||||
}"
|
||||
>
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.groupCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Groups") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 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-zinc-50 dark:bg-mbz-purple-500 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-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<router-link
|
||||
:to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }"
|
||||
>
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.reportsCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Reports") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<span class="mb-4 font-semibold block">{{
|
||||
formatBytes(instance.mediaSize)
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Uploaded media size") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid xl:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md"
|
||||
v-if="instance.hasRelay"
|
||||
>
|
||||
<button
|
||||
@click="
|
||||
removeInstanceFollow({
|
||||
address: instance?.relayAddress,
|
||||
})
|
||||
"
|
||||
v-if="instance.followedStatus == InstanceFollowStatus.APPROVED"
|
||||
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||
>
|
||||
{{ $t("Stop following instance") }}
|
||||
</button>
|
||||
<button
|
||||
@click="
|
||||
removeInstanceFollow({
|
||||
address: instance?.relayAddress,
|
||||
})
|
||||
"
|
||||
v-else-if="instance.followedStatus == InstanceFollowStatus.PENDING"
|
||||
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||
>
|
||||
{{ $t("Cancel follow request") }}
|
||||
</button>
|
||||
<button
|
||||
@click="followInstance"
|
||||
v-else
|
||||
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||
>
|
||||
{{ $t("Follow instance") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="md:h-48 py-16 text-center opacity-50">
|
||||
{{ $t("Only Mobilizon instances can be followed") }}
|
||||
</div>
|
||||
<div
|
||||
class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md flex flex-col gap-2"
|
||||
>
|
||||
<button
|
||||
@click="
|
||||
acceptInstance({
|
||||
address: instance?.relayAddress,
|
||||
})
|
||||
"
|
||||
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
|
||||
class="bg-green-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||
>
|
||||
{{ $t("Accept follow") }}
|
||||
</button>
|
||||
<button
|
||||
@click="
|
||||
rejectInstance({
|
||||
address: instance?.relayAddress,
|
||||
})
|
||||
"
|
||||
v-if="instance.followerStatus != InstanceFollowStatus.NONE"
|
||||
class="bg-red-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||
>
|
||||
{{ $t("Reject follow") }}
|
||||
</button>
|
||||
<p v-if="instance.followerStatus == InstanceFollowStatus.NONE">
|
||||
{{ $t("This instance doesn't follow yours.") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ACCEPT_RELAY,
|
||||
ADD_INSTANCE,
|
||||
INSTANCE,
|
||||
REJECT_RELAY,
|
||||
REMOVE_RELAY,
|
||||
} from "@/graphql/admin";
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import RouteName from "@/router/name";
|
||||
import { IInstance } from "@/types/instance.model";
|
||||
import { ApolloCache, gql, Reference } from "@apollo/client/core";
|
||||
import { InstanceFollowStatus } from "@/types/enums";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, inject } from "vue";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
|
||||
const props = defineProps<{ domain: string }>();
|
||||
|
||||
const { result: instanceResult } = useQuery<{ instance: IInstance }>(
|
||||
INSTANCE,
|
||||
() => ({ domain: props.domain })
|
||||
);
|
||||
|
||||
const instance = computed(() => instanceResult.value?.instance);
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const { mutate: acceptInstance, onError: onAcceptInstanceError } = useMutation(
|
||||
ACCEPT_RELAY,
|
||||
() => ({
|
||||
update(cache: ApolloCache<any>) {
|
||||
cache.writeFragment({
|
||||
id: cache.identify(instance.value as unknown as Reference),
|
||||
fragment: gql`
|
||||
fragment InstanceFollowerStatus on Instance {
|
||||
followerStatus
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
followerStatus: InstanceFollowStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
onAcceptInstanceError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reject instance follow
|
||||
*/
|
||||
const { mutate: rejectInstance, onError: onRejectInstanceError } = useMutation(
|
||||
REJECT_RELAY,
|
||||
() => ({
|
||||
update(cache: ApolloCache<any>) {
|
||||
cache.writeFragment({
|
||||
id: cache.identify(instance as unknown as Reference),
|
||||
fragment: gql`
|
||||
fragment InstanceFollowerStatus on Instance {
|
||||
followerStatus
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
followerStatus: InstanceFollowStatus.NONE,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
onRejectInstanceError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const { mutate: followInstanceMutation, onError: onFollowInstanceError } =
|
||||
useMutation<{ addInstance: IInstance }>(ADD_INSTANCE);
|
||||
|
||||
onFollowInstanceError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const followInstance = async (e: Event): Promise<void> => {
|
||||
e.preventDefault();
|
||||
followInstanceMutation({ domain: props.domain });
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop following instance
|
||||
*/
|
||||
const { mutate: removeInstanceFollow, onError: onRemoveInstanceFollowError } =
|
||||
useMutation(REMOVE_RELAY, () => ({
|
||||
update(cache: ApolloCache<any>) {
|
||||
cache.writeFragment({
|
||||
id: cache.identify(instance.value as unknown as Reference),
|
||||
fragment: gql`
|
||||
fragment InstanceFollowedStatus on Instance {
|
||||
followedStatus
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
followedStatus: InstanceFollowStatus.NONE,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
onRemoveInstanceFollowError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
283
src/views/Admin/InstancesView.vue
Normal file
283
src/views/Admin/InstancesView.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{ text: t('Instances') },
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1 class="title">{{ t("Instances") }}</h1>
|
||||
<form @submit="followInstance" class="my-4">
|
||||
<o-field
|
||||
:label="t('Follow a new instance')"
|
||||
horizontal
|
||||
label-for="newRelayAddress"
|
||||
>
|
||||
<o-field grouped group-multiline expanded size="large">
|
||||
<p class="control">
|
||||
<o-input
|
||||
id="newRelayAddress"
|
||||
v-model="newRelayAddress"
|
||||
:placeholder="t('Ex: mobilizon.fr')"
|
||||
/>
|
||||
</p>
|
||||
<p class="control">
|
||||
<o-button variant="primary" native-type="submit">{{
|
||||
t("Add an instance")
|
||||
}}</o-button>
|
||||
<o-loading
|
||||
:is-full-page="true"
|
||||
v-model="followInstanceLoading"
|
||||
:can-cancel="false"
|
||||
/>
|
||||
</p>
|
||||
</o-field>
|
||||
</o-field>
|
||||
</form>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<o-field :label="t('Follow status')">
|
||||
<o-radio
|
||||
v-model="followStatus"
|
||||
:native-value="InstanceFilterFollowStatus.ALL"
|
||||
>{{ t("All") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
v-model="followStatus"
|
||||
:native-value="InstanceFilterFollowStatus.FOLLOWING"
|
||||
>{{ t("Following") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
v-model="followStatus"
|
||||
:native-value="InstanceFilterFollowStatus.FOLLOWED"
|
||||
>{{ t("Followed") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Domain')"
|
||||
label-for="domain-filter"
|
||||
class="flex-auto"
|
||||
>
|
||||
<o-input
|
||||
id="domain-filter"
|
||||
:placeholder="t('mobilizon-instance.tld')"
|
||||
:value="filterDomain"
|
||||
@input="debouncedUpdateDomainFilter"
|
||||
/>
|
||||
</o-field>
|
||||
</div>
|
||||
<div v-if="instances && instances.elements.length > 0" class="my-3">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: instance.domain },
|
||||
}"
|
||||
class="flex items-center mb-2 rounded bg-mbz-yellow-alt-300 dark:bg-mbz-purple-400 p-4 flex-wrap justify-center gap-x-2 gap-y-3"
|
||||
v-for="instance in instances.elements"
|
||||
:key="instance.domain"
|
||||
>
|
||||
<div class="grow overflow-hidden flex items-center gap-1">
|
||||
<img
|
||||
class="w-12"
|
||||
v-if="instance.hasRelay"
|
||||
src="../../../public/img/logo.svg"
|
||||
alt=""
|
||||
/>
|
||||
<CloudQuestion v-else :size="36" />
|
||||
|
||||
<div class="">
|
||||
<h3 class="text-lg truncate">{{ instance.domain }}</h3>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-down" />
|
||||
{{ t("Followed") }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-else-if="
|
||||
instance.followedStatus === InstanceFollowStatus.PENDING
|
||||
"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-down" />
|
||||
{{ t("Followed, pending response") }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-if="instance.followerStatus == InstanceFollowStatus.APPROVED"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-up" />
|
||||
{{ t("Follows us") }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-up" />
|
||||
{{ t("Follows us, pending approval") }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none flex gap-3 ltr:ml-3 rtl:mr-3">
|
||||
<p class="flex flex-col text-center">
|
||||
<span class="text-xl">{{ instance.eventCount }}</span
|
||||
><span class="text-sm">{{ t("Events") }}</span>
|
||||
</p>
|
||||
<p class="flex flex-col text-center">
|
||||
<span class="text-xl">{{ instance.personCount }}</span
|
||||
><span class="text-sm">{{ t("Profiles") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
<o-pagination
|
||||
v-show="instances.total > INSTANCES_PAGE_LIMIT"
|
||||
:total="instances.total"
|
||||
v-model:current="instancePage"
|
||||
:per-page="INSTANCES_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<div v-else-if="instances && instances.elements.length == 0">
|
||||
<empty-content icon="lan-disconnect" :inline="true">
|
||||
{{ t("No instance found.") }}
|
||||
<template #desc>
|
||||
<span v-if="hasFilter">
|
||||
{{
|
||||
t(
|
||||
"No instances match this filter. Try resetting filter fields?"
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t("You haven't interacted with other instances yet.") }}
|
||||
</span>
|
||||
</template>
|
||||
</empty-content>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ADD_INSTANCE, INSTANCES } from "@/graphql/admin";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import RouteName from "../../router/name";
|
||||
import { IInstance } from "@/types/instance.model";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import {
|
||||
InstanceFilterFollowStatus,
|
||||
InstanceFollowStatus,
|
||||
} from "@/types/enums";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
enumTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
|
||||
const INSTANCES_PAGE_LIMIT = 10;
|
||||
|
||||
const instancePage = useRouteQuery("page", 1, integerTransformer);
|
||||
const filterDomain = useRouteQuery("filterDomain", "");
|
||||
const followStatus = useRouteQuery(
|
||||
"followStatus",
|
||||
InstanceFilterFollowStatus.ALL,
|
||||
enumTransformer(InstanceFilterFollowStatus)
|
||||
);
|
||||
|
||||
const { result: instancesResult } = useQuery<{
|
||||
instances: Paginate<IInstance>;
|
||||
}>(INSTANCES, () => ({
|
||||
page: instancePage.value,
|
||||
limit: INSTANCES_PAGE_LIMIT,
|
||||
filterDomain: filterDomain.value,
|
||||
filterFollowStatus: followStatus.value,
|
||||
}));
|
||||
|
||||
const instances = computed(() => instancesResult.value?.instances);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Federation")),
|
||||
});
|
||||
|
||||
const followInstanceLoading = ref(false);
|
||||
|
||||
const newRelayAddress = ref("");
|
||||
|
||||
// relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
|
||||
|
||||
// relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
|
||||
|
||||
const updateDomainFilter = (event: InputEvent) => {
|
||||
const newValue = (event.target as HTMLInputElement).value;
|
||||
filterDomain.value = newValue;
|
||||
};
|
||||
|
||||
const debouncedUpdateDomainFilter = debounce(updateDomainFilter, 500);
|
||||
|
||||
const hasFilter = computed((): boolean => {
|
||||
return (
|
||||
followStatus.value !== InstanceFilterFollowStatus.ALL ||
|
||||
filterDomain.value !== ""
|
||||
);
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, onDone, onError } = useMutation<{
|
||||
addInstance: IInstance;
|
||||
}>(ADD_INSTANCE);
|
||||
|
||||
onDone(({ data }) => {
|
||||
newRelayAddress.value = "";
|
||||
followInstanceLoading.value = false;
|
||||
router.push({
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: data?.addInstance.domain },
|
||||
});
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
onError((error) => {
|
||||
if (error.message) {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
}
|
||||
followInstanceLoading.value = false;
|
||||
});
|
||||
|
||||
const followInstance = async (e: Event): Promise<void> => {
|
||||
e.preventDefault();
|
||||
followInstanceLoading.value = true;
|
||||
const domain = newRelayAddress.value.trim(); // trim to fix copy and paste domain name spaces and tabs
|
||||
mutate({
|
||||
domain,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.tab-item {
|
||||
form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
</style>
|
||||
164
src/views/Admin/ProfilesView.vue
Normal file
164
src/views/Admin/ProfilesView.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: t('Moderation') },
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
text: t('Profiles'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="persons">
|
||||
<div class="flex gap-2">
|
||||
<o-switch v-model="local">{{ t("Local") }}</o-switch>
|
||||
<o-switch v-model="suspended">{{ t("Suspended") }}</o-switch>
|
||||
</div>
|
||||
<o-table
|
||||
:data="persons.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
backend-filtering
|
||||
:debounce-search="500"
|
||||
v-model:current-page="page"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
:total="persons.total"
|
||||
:per-page="PROFILES_PER_PAGE"
|
||||
@filters-change="onFiltersChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="preferredUsername"
|
||||
:label="t('Username')"
|
||||
searchable
|
||||
>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
v-model="props.filters.preferredUsername"
|
||||
:aria-label="t('Filter')"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
<router-link
|
||||
class="profile"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
}"
|
||||
>
|
||||
<article class="flex gap-2">
|
||||
<figure class="" v-if="props.row.avatar">
|
||||
<img
|
||||
:src="props.row.avatar.url"
|
||||
:alt="props.row.avatar.alt || ''"
|
||||
width="48"
|
||||
height="48"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</figure>
|
||||
<Account v-else :size="48" />
|
||||
<div class="">
|
||||
<div class="prose dark:prose-invert">
|
||||
<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>
|
||||
</template>
|
||||
</o-table-column>
|
||||
|
||||
<o-table-column field="domain" :label="t('Domain')" searchable>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
v-model="props.filters.domain"
|
||||
:aria-label="t('Filter')"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
{{ props.row.domain }}
|
||||
</template>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account" :inline="true">
|
||||
{{ t("No profile matches the filters") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LIST_PROFILES } from "@/graphql/actor";
|
||||
import RouteName from "@/router/name";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed } from "vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import {
|
||||
useRouteQuery,
|
||||
booleanTransformer,
|
||||
integerTransformer,
|
||||
} from "vue-use-route-query";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { IPerson } from "@/types/actor/person.model";
|
||||
import Account from "vue-material-design-icons/Account.vue";
|
||||
|
||||
const PROFILES_PER_PAGE = 10;
|
||||
|
||||
const preferredUsername = useRouteQuery("preferredUsername", "");
|
||||
const name = useRouteQuery("name", "");
|
||||
const domain = useRouteQuery("domain", "");
|
||||
|
||||
const local = useRouteQuery("local", domain.value === "", booleanTransformer);
|
||||
const suspended = useRouteQuery("suspended", false, booleanTransformer);
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
|
||||
const {
|
||||
result: personResult,
|
||||
loading,
|
||||
fetchMore,
|
||||
} = useQuery<{ persons: Paginate<IPerson> }>(LIST_PROFILES, () => ({
|
||||
preferredUsername: preferredUsername.value,
|
||||
name: name.value,
|
||||
domain: domain.value,
|
||||
local: local.value,
|
||||
suspended: suspended.value,
|
||||
page: page.value,
|
||||
limit: PROFILES_PER_PAGE,
|
||||
}));
|
||||
|
||||
const persons = computed(() => personResult.value?.persons);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Profiles")),
|
||||
});
|
||||
|
||||
const onFiltersChange = ({
|
||||
preferredUsername: newPreferredUsername,
|
||||
domain: newDomain,
|
||||
}: {
|
||||
preferredUsername: string;
|
||||
domain: string;
|
||||
}): void => {
|
||||
preferredUsername.value = newPreferredUsername;
|
||||
domain.value = newDomain;
|
||||
fetchMore({});
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
a.profile {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
511
src/views/Admin/SettingsView.vue
Normal file
511
src/views/Admin/SettingsView.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{ text: t('Instance settings') },
|
||||
]"
|
||||
/>
|
||||
|
||||
<section v-if="settingsToWrite">
|
||||
<form @submit.prevent="updateSettings">
|
||||
<o-field :label="t('Instance Name')" label-for="instance-name">
|
||||
<o-input v-model="settingsToWrite.instanceName" id="instance-name" />
|
||||
</o-field>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-description">{{
|
||||
t("Instance Short Description")
|
||||
}}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
"Displayed on homepage and meta tags. Describe what Mobilizon is and what makes this instance special in a single paragraph."
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceDescription"
|
||||
rows="2"
|
||||
id="instance-description"
|
||||
/>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-slogan">{{
|
||||
t("Instance Slogan")
|
||||
}}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
'A short tagline for your instance homepage. Defaults to "Gather ⋅ Organize ⋅ Mobilize"'
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
v-model="settingsToWrite.instanceSlogan"
|
||||
:placeholder="t('Gather ⋅ Organize ⋅ Mobilize')"
|
||||
id="instance-slogan"
|
||||
/>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-contact">{{ t("Contact") }}</label>
|
||||
<small>
|
||||
{{ t("Can be an email or a link, or just plain text.") }}
|
||||
</small>
|
||||
<o-input v-model="settingsToWrite.contact" id="instance-contact" />
|
||||
</div>
|
||||
<o-field :label="t('Allow registrations')">
|
||||
<o-switch v-model="settingsToWrite.registrationsOpen">
|
||||
<p
|
||||
class="prose dark:prose-invert"
|
||||
v-if="settingsToWrite.registrationsOpen"
|
||||
>
|
||||
{{ t("Registration is allowed, anyone can register.") }}
|
||||
</p>
|
||||
<p class="prose dark:prose-invert" v-else>
|
||||
{{ t("Registration is closed.") }}
|
||||
</p>
|
||||
</o-switch>
|
||||
</o-field>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-languages">{{
|
||||
t("Instance languages")
|
||||
}}</label>
|
||||
<small>
|
||||
{{ t("Main languages you/your moderators speak") }}
|
||||
</small>
|
||||
<o-inputitems
|
||||
v-model="instanceLanguages"
|
||||
:data="filteredLanguages"
|
||||
allow-autocomplete
|
||||
:open-on-focus="true"
|
||||
field="name"
|
||||
icon="label"
|
||||
:placeholder="t('Select languages')"
|
||||
@typing="getFilteredLanguages"
|
||||
id="instance-languages"
|
||||
>
|
||||
<template #empty>{{ t("No languages found") }}</template>
|
||||
</o-inputitems>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-long-description">{{
|
||||
t("Instance Long Description")
|
||||
}}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
"A place to explain who you are and the things that set your instance apart. You can use HTML tags."
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceLongDescription"
|
||||
rows="4"
|
||||
id="instance-long-description"
|
||||
/>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-rules">{{ t("Instance Rules") }}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
"A place for your code of conduct, rules or guidelines. You can use HTML tags."
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceRules"
|
||||
id="instance-rules"
|
||||
/>
|
||||
</div>
|
||||
<o-field :label="t('Instance Terms Source')">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ t("Choose the source of the instance's Terms") }}
|
||||
</legend>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instanceTermsType"
|
||||
name="instanceTermsType"
|
||||
:native-value="InstanceTermsType.DEFAULT"
|
||||
>{{ t("Default Mobilizon terms") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instanceTermsType"
|
||||
name="instanceTermsType"
|
||||
:native-value="InstanceTermsType.URL"
|
||||
>{{ t("Custom URL") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instanceTermsType"
|
||||
name="instanceTermsType"
|
||||
:native-value="InstanceTermsType.CUSTOM"
|
||||
>{{ t("Custom text") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="">
|
||||
<o-notification
|
||||
class="bg-slate-700"
|
||||
v-if="
|
||||
settingsToWrite.instanceTermsType ===
|
||||
InstanceTermsType.DEFAULT
|
||||
"
|
||||
>
|
||||
<b>{{ t("Default") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="The {default_terms} will be used. They will be translated in the user's language."
|
||||
>
|
||||
<template #default_terms>
|
||||
<a
|
||||
href="https://demo.mobilizon.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ t("default Mobilizon terms") }}</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<b>{{
|
||||
t(
|
||||
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer."
|
||||
)
|
||||
}}</b>
|
||||
</o-notification>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instanceTermsType === InstanceTermsType.URL
|
||||
"
|
||||
>
|
||||
<b>{{ t("URL") }}</b>
|
||||
<p class="prose dark:prose-invert">
|
||||
{{ t("Set an URL to a page with your own terms.") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instanceTermsType === InstanceTermsType.CUSTOM
|
||||
"
|
||||
>
|
||||
<b>{{ t("Custom") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="Enter your own terms. HTML tags allowed. The {mobilizon_terms} are provided as template."
|
||||
>
|
||||
<template #mobilizon_terms>
|
||||
<a
|
||||
href="https://demo.mobilizon.org/terms"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ t("default Mobilizon terms") }}</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Terms URL')"
|
||||
label-for="instanceTermsUrl"
|
||||
v-if="settingsToWrite.instanceTermsType === InstanceTermsType.URL"
|
||||
>
|
||||
<o-input
|
||||
type="URL"
|
||||
v-model="settingsToWrite.instanceTermsUrl"
|
||||
id="instanceTermsUrl"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Terms')"
|
||||
label-for="instanceTerms"
|
||||
v-if="settingsToWrite.instanceTermsType === InstanceTermsType.CUSTOM"
|
||||
>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceTerms"
|
||||
id="instanceTerms"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field :label="t('Instance Privacy Policy Source')">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ t("Choose the source of the instance's Privacy Policy") }}
|
||||
</legend>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instancePrivacyPolicyType"
|
||||
name="instancePrivacyType"
|
||||
:native-value="InstancePrivacyType.DEFAULT"
|
||||
>{{ t("Default Mobilizon privacy policy") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instancePrivacyPolicyType"
|
||||
name="instancePrivacyType"
|
||||
:native-value="InstancePrivacyType.URL"
|
||||
>{{ t("Custom URL") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instancePrivacyPolicyType"
|
||||
name="instancePrivacyType"
|
||||
:native-value="InstancePrivacyType.CUSTOM"
|
||||
>{{ t("Custom text") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.DEFAULT
|
||||
"
|
||||
>
|
||||
<b>{{ t("Default") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="The {default_privacy_policy} will be used. They will be translated in the user's language."
|
||||
>
|
||||
<template #default_privacy_policy>
|
||||
<a
|
||||
href="https://demo.mobilizon.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ t("default Mobilizon privacy policy") }}</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.URL
|
||||
"
|
||||
>
|
||||
<b>{{ t("URL") }}</b>
|
||||
<p class="prose dark:prose-invert">
|
||||
{{ t("Set an URL to a page with your own privacy policy.") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.CUSTOM
|
||||
"
|
||||
>
|
||||
<b>{{ t("Custom") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="Enter your own privacy policy. HTML tags allowed. The {mobilizon_privacy_policy} is provided as template."
|
||||
>
|
||||
<template #mobilizon_privacy_policy>
|
||||
<a
|
||||
href="https://demo.mobilizon.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ t("default Mobilizon privacy policy") }}</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Privacy Policy URL')"
|
||||
label-for="instancePrivacyPolicyUrl"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.URL
|
||||
"
|
||||
>
|
||||
<o-input
|
||||
type="URL"
|
||||
v-model="settingsToWrite.instancePrivacyPolicyUrl"
|
||||
id="instancePrivacyPolicyUrl"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Privacy Policy')"
|
||||
label-for="instancePrivacyPolicy"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.CUSTOM
|
||||
"
|
||||
>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instancePrivacyPolicy"
|
||||
id="instancePrivacyPolicy"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Save")
|
||||
}}</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ADMIN_SETTINGS,
|
||||
SAVE_ADMIN_SETTINGS,
|
||||
LANGUAGES,
|
||||
} from "@/graphql/admin";
|
||||
import { InstancePrivacyType, InstanceTermsType } from "@/types/enums";
|
||||
import { IAdminSettings, ILanguage } from "@/types/admin.model";
|
||||
import RouteName from "@/router/name";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { ref, computed, watch, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import type { Notifier } from "@/plugins/notifier";
|
||||
|
||||
const defaultAdminSettings: IAdminSettings = {
|
||||
instanceName: "",
|
||||
instanceDescription: "",
|
||||
instanceSlogan: "",
|
||||
instanceLongDescription: "",
|
||||
contact: "",
|
||||
instanceTerms: "",
|
||||
instanceTermsType: InstanceTermsType.DEFAULT,
|
||||
instanceTermsUrl: null,
|
||||
instancePrivacyPolicy: "",
|
||||
instancePrivacyPolicyType: InstancePrivacyType.DEFAULT,
|
||||
instancePrivacyPolicyUrl: null,
|
||||
instanceRules: "",
|
||||
registrationsOpen: false,
|
||||
instanceLanguages: [],
|
||||
};
|
||||
|
||||
const { result: adminSettingsResult } = useQuery<{
|
||||
adminSettings: IAdminSettings;
|
||||
}>(ADMIN_SETTINGS);
|
||||
const adminSettings = computed(
|
||||
() => adminSettingsResult.value?.adminSettings ?? defaultAdminSettings
|
||||
);
|
||||
|
||||
const { result: languageResult } = useQuery<{ languages: ILanguage[] }>(
|
||||
LANGUAGES
|
||||
);
|
||||
const languages = computed(() => languageResult.value?.languages);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Settings")),
|
||||
});
|
||||
|
||||
const settingsToWrite = ref<IAdminSettings>(defaultAdminSettings);
|
||||
|
||||
watch(adminSettings, () => {
|
||||
settingsToWrite.value = { ...adminSettings.value };
|
||||
});
|
||||
|
||||
const filteredLanguages = ref<string[]>([]);
|
||||
|
||||
const instanceLanguages = computed({
|
||||
get() {
|
||||
const languageCodes = [...(adminSettings.value?.instanceLanguages ?? [])];
|
||||
return languageCodes
|
||||
.map((code) => languageForCode(code))
|
||||
.filter((language) => language) as string[];
|
||||
},
|
||||
set(newInstanceLanguages: string[]) {
|
||||
const newFilteredInstanceLanguages = newInstanceLanguages
|
||||
.map((language) => {
|
||||
return codeForLanguage(language);
|
||||
})
|
||||
.filter((code) => code !== undefined) as string[];
|
||||
settingsToWrite.value = {
|
||||
...settingsToWrite.value,
|
||||
instanceLanguages: newFilteredInstanceLanguages,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const {
|
||||
mutate: saveAdminSettings,
|
||||
onDone: saveAdminSettingsDone,
|
||||
onError: saveAdminSettingsError,
|
||||
} = useMutation(SAVE_ADMIN_SETTINGS);
|
||||
|
||||
saveAdminSettingsDone(() => {
|
||||
notifier?.success(t("Admin settings successfully saved.") as string);
|
||||
});
|
||||
|
||||
saveAdminSettingsError((e) => {
|
||||
console.error(e);
|
||||
notifier?.error(t("Failed to save admin settings") as string);
|
||||
});
|
||||
|
||||
const updateSettings = async (): Promise<void> => {
|
||||
const variables = { ...settingsToWrite.value };
|
||||
console.debug("updating settings with variables", variables);
|
||||
saveAdminSettings(variables);
|
||||
};
|
||||
|
||||
const getFilteredLanguages = (text: string): void => {
|
||||
filteredLanguages.value = languages.value
|
||||
? languages.value
|
||||
.filter((language: ILanguage) => {
|
||||
return (
|
||||
language.name
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.indexOf(text.toLowerCase()) >= 0
|
||||
);
|
||||
})
|
||||
.map(({ name }) => name)
|
||||
: [];
|
||||
};
|
||||
|
||||
const codeForLanguage = (language: string): string | undefined => {
|
||||
if (languages.value) {
|
||||
const lang = languages.value.find(({ name }) => name === language);
|
||||
if (lang) return lang.code;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const languageForCode = (codeGiven: string): string | undefined => {
|
||||
if (languages.value) {
|
||||
const lang = languages.value.find(({ code }) => code === codeGiven);
|
||||
if (lang) return lang.name;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
label.label.has-help {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
201
src/views/Admin/UsersView.vue
Normal file
201
src/views/Admin/UsersView.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: $t('Moderation') },
|
||||
{
|
||||
name: RouteName.USERS,
|
||||
text: $t('Users'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="users">
|
||||
<form @submit.prevent="activateFilters">
|
||||
<o-field class="mb-5" grouped group-multiline>
|
||||
<o-field :label="$t('Email')" expanded>
|
||||
<o-input trap-focus icon="email" v-model="emailFilterFieldValue" />
|
||||
</o-field>
|
||||
<o-field :label="$t('IP Address')" expanded>
|
||||
<o-input icon="web" v-model="ipFilterFieldValue" />
|
||||
</o-field>
|
||||
<p class="control self-end mb-0">
|
||||
<o-button variant="primary" native-type="submit">{{
|
||||
$t("Filter")
|
||||
}}</o-button>
|
||||
</p>
|
||||
</o-field>
|
||||
</form>
|
||||
<o-table
|
||||
:data="users.elements"
|
||||
:loading="usersLoading"
|
||||
paginated
|
||||
backend-pagination
|
||||
:debounce-search="500"
|
||||
v-model:current-page="page"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:show-detail-icon="true"
|
||||
:total="users.total"
|
||||
:per-page="USERS_PER_PAGE"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<o-table-column field="id" width="40" numeric v-slot="props">
|
||||
{{ props.row.id }}
|
||||
</o-table-column>
|
||||
<o-table-column field="email" :label="$t('Email')">
|
||||
<template #default="props">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
}"
|
||||
:class="{ disabled: props.row.disabled }"
|
||||
>
|
||||
{{ props.row.email }}
|
||||
</router-link>
|
||||
</template>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="confirmedAt"
|
||||
:label="$t('Last seen on')"
|
||||
:centered="true"
|
||||
v-slot="props"
|
||||
>
|
||||
<template v-if="props.row.currentSignInAt">
|
||||
<time :datetime="props.row.currentSignInAt">
|
||||
{{ formatDateTimeString(props.row.currentSignInAt) }}
|
||||
</time>
|
||||
</template>
|
||||
<template v-else-if="props.row.confirmedAt"> - </template>
|
||||
<template v-else>
|
||||
{{ $t("Not confirmed") }}
|
||||
</template>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="locale"
|
||||
:label="$t('Language')"
|
||||
:centered="true"
|
||||
v-slot="props"
|
||||
>
|
||||
{{ getLanguageNameForCode(props.row.locale) }}
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content
|
||||
v-if="!usersLoading && emailFilter"
|
||||
:inline="true"
|
||||
icon="account"
|
||||
>
|
||||
{{ $t("No user matches the filters") }}
|
||||
<template #desc>
|
||||
<o-button variant="primary" @click="resetFilters">
|
||||
{{ $t("Reset filters") }}
|
||||
</o-button>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LIST_USERS } from "../../graphql/user";
|
||||
import RouteName from "../../router/name";
|
||||
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";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { ILanguage } from "@/types/admin.model";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
|
||||
import { formatDateTimeString } from "@/filters/datetime";
|
||||
|
||||
const USERS_PER_PAGE = 10;
|
||||
|
||||
const emailFilter = useRouteQuery("emailFilter", "");
|
||||
const ipFilter = useRouteQuery("ipFilter", "");
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
|
||||
const languagesCodes = computed((): string[] => {
|
||||
return (users.value?.elements ?? []).map((user: IUser) => user.locale);
|
||||
});
|
||||
|
||||
const {
|
||||
result: usersResult,
|
||||
fetchMore,
|
||||
loading: usersLoading,
|
||||
} = useQuery<{ users: Paginate<IUser> }>(LIST_USERS, () => ({
|
||||
email: emailFilter.value,
|
||||
currentSignInIp: ipFilter.value,
|
||||
page: page.value,
|
||||
limit: USERS_PER_PAGE,
|
||||
}));
|
||||
|
||||
const users = computed(() => usersResult.value?.users);
|
||||
|
||||
const { result: languagesResult } = useQuery<{ languages: ILanguage[] }>(
|
||||
LANGUAGES_CODES,
|
||||
() => ({
|
||||
codes: languagesCodes.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled: languagesCodes.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const languages = computed(() => languagesResult.value?.languages);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Users")),
|
||||
});
|
||||
|
||||
const emailFilterFieldValue = ref(emailFilter.value);
|
||||
const ipFilterFieldValue = ref(ipFilter.value);
|
||||
|
||||
const getLanguageNameForCode = (code: string): string => {
|
||||
return (
|
||||
(languages.value ?? []).find(({ code: languageCode }) => {
|
||||
return languageCode === code;
|
||||
})?.name || code
|
||||
);
|
||||
};
|
||||
|
||||
const onPageChange = async (newPage: number): Promise<void> => {
|
||||
page.value = newPage;
|
||||
await fetchMore({
|
||||
variables: {
|
||||
email: emailFilter.value,
|
||||
currentSignInIp: ipFilter.value,
|
||||
page: page.value,
|
||||
limit: USERS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const activateFilters = (): void => {
|
||||
emailFilter.value = emailFilterFieldValue.value;
|
||||
ipFilter.value = ipFilterFieldValue.value;
|
||||
};
|
||||
|
||||
const resetFilters = (): void => {
|
||||
emailFilterFieldValue.value = "";
|
||||
ipFilterFieldValue.value = "";
|
||||
activateFilters();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
a.profile,
|
||||
a.user-profile {
|
||||
text-decoration: none;
|
||||
}
|
||||
a.disabled {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user