PronounsPage/routes/adminUsers.vue
2024-09-12 10:11:25 +02:00

224 lines
9.5 KiB
Vue

<template>
<Page>
<NotFound v-if="!$isGranted('users') && !$isGranted('community')" />
<div v-else>
<p>
<nuxt-link to="/admin">
<Icon v="user-cog" />
<T>admin.header</T>
</nuxt-link>
</p>
<h2>
<Icon v="user-cog" />
Users
</h2>
<section>
<details class="border mb-3" @click="showUsers = true">
<summary class="bg-light p-3">
<Icon v="users" />
Users
({{ stats._.users }} overall, {{ stats._.admins }} admins)
</summary>
<div v-if="showUsers" class="border-top">
<div class="input-group mt-4">
<input v-model="userFilter" class="form-control" :placeholder="$t('crud.filterLong')">
<button
:class="['btn', adminsFilter ? 'btn-secondary' : 'btn-outline-secondary']"
@click="adminsFilter = !adminsFilter"
>
Only admins
</button>
<button
:class="['btn', localeFilter ? 'btn-secondary' : 'btn-outline-secondary']"
@click="localeFilter = !localeFilter"
>
Only this version
</button>
</div>
<ServerTable
endpoint="/api/admin/users"
:query="{ filter: userFilterDelayed || undefined, localeFilter: localeFilter || undefined, adminsFilter: adminsFilter || undefined }"
:columns="5"
count
>
<template #header>
<th class="text-nowrap">
<T>admin.user.user</T>
</th>
<th class="text-nowrap">
<T>admin.user.createdAt</T>
</th>
<th class="text-nowrap">
<T>admin.user.email</T>
</th>
<th class="text-nowrap">
<T>admin.user.roles</T>
</th>
<th class="text-nowrap">
<T>admin.user.profiles</T>
</th>
</template>
<template #row="s">
<td>
<a :href="`https://pronouns.page/@${s.el.username}`">@{{ s.el.username }}</a>
<a v-if="$isGranted('*') || $isGranted('impersonate')" href="#" class="badge bg-primary text-white" @click.prevent="impersonate(s.el.email)"><Icon v="user-secret" /></a>
<nuxt-link v-if="$isGranted('*')" :to="`/admin/audit-log/${s.el.username}/${s.el.id}`" class="badge bg-primary text-white">
<Icon v="file-search" />
</nuxt-link>
<a v-if="$isGranted('*') || $isGranted('community')" href="#" class="badge bg-danger text-white" @click.prevent="erasure(s.el.id, s.el.email)"><Icon v="truck-plow" /></a>
</td>
<td>
{{ $datetime($ulidTime(s.el.id)) }}
</td>
<td>
<p>
<a :href="`mailto:${s.el.email}`" target="_blank" rel="noopener">
{{ s.el.email }}
</a>
</p>
<!--
<ul v-if="s.el.socialConnections.length" class="list-inline">
<li v-for="conn in s.el.socialConnections" class="list-inline-item">
<Icon :v="socialProviders[conn].icon || conn" set="b"/>
</li>
</ul>
-->
</td>
<td>
<Roles :user="s.el" />
</td>
<td>
<ul class="list-unstyled">
<template v-for="locale in s.el.profiles">
<li v-if="$locales[locale]" :key="locale">
<LocaleLink :link="`/@${s.el.username}`" :locale="locale">
{{ $locales[locale].name }}
</LocaleLink>
</li>
</template>
</ul>
</td>
</template>
</ServerTable>
</div>
</details>
</section>
<section>
<ChartSet name="users" :data="chart" init="cumulative" />
</section>
<section>
<Chart
label="number of profiles by locale"
:data="profilesByLocale"
type="bar"
:options="{
indexAxis: 'y',
responsive: true,
interaction: {
intersect: false,
mode: 'y',
},
}"
/>
</section>
</div>
</Page>
</template>
<script>
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
import { useNuxtApp } from 'nuxt/app';
import useSimpleHead from '../composables/useSimpleHead.ts';
import { socialProviders } from '../src/socialProviders.ts';
import useConfig from '../composables/useConfig.ts';
import useDialogue from '../composables/useDialogue.ts';
export default {
async setup() {
const { $translator: translator } = useNuxtApp();
const dialogue = useDialogue();
useSimpleHead({
title: `${translator.translate('admin.header')} • Users`,
}, translator);
const tokenCookie = useCookie('token', longtimeCookieSetting);
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
const stats = useFetch('/api/admin/stats');
const chart = useFetch('/api/admin/stats/users-chart/_');
await Promise.all([stats, chart]);
return {
config: useConfig(),
dialogue,
tokenCookie,
impersonatorCookie,
stats: stats.data.value,
chart: chart.data.value,
};
},
data() {
return {
socialProviders,
showUsers: false,
userFilter: '',
userFilterDelayed: '',
userFilterDelayHandle: undefined,
localeFilter: true,
adminsFilter: false,
};
},
computed: {
profilesByLocale() {
const r = {};
Object.entries(this.stats)
.filter(([locale, _localeStats]) => locale !== '_' && locale !== 'calculatedAt')
.sort(([_aLocale, aLocaleStats], [_bLocale, bLocaleStats]) => bLocaleStats.users - aLocaleStats.users)
.forEach(([locale, localeStats]) => {
r[locale] = localeStats.users;
});
return r;
},
},
watch: {
userFilter() {
if (this.userFilterDelayHandle !== undefined) {
clearInterval(this.userFilterDelayHandle);
}
this.userFilterDelayHandle = setTimeout(() => {
this.userFilterDelayed = this.userFilter;
}, 750);
},
},
methods: {
async impersonate(email) {
const { token } = await this.$fetch(`/api/admin/impersonate/${encodeURIComponent(email)}`);
this.impersonatorCookie = this.tokenCookie;
this.tokenCookie = token;
await this.$router.push(`/${this.config.user.route}`);
setTimeout(() => window.location.reload(), 500);
},
async erasure(id, email) {
await this.dialogue.confirm(`Are you sure you want to remove this account (${email})? ` +
'This should only be done in two cases: ' +
'an explicit GPDR request directly from the user, ' +
'or having proof that owner is not yet 13 years old.', 'danger');
if (await this.$csrfFetch(`/api/user/data-erasure/${id}`, { method: 'POST' })) {
await this.dialogue.alert(`Account ${email} removed successfully`, 'success');
} else {
await this.dialogue.alert(this.$t('error.generic', 'danger'));
}
},
},
};
</script>