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:
Thomas Citharel
2023-11-14 17:24:42 +01:00
parent 32055122c3
commit 2e72f6faf4
595 changed files with 12078 additions and 7843 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>