user list for moderation of pending users - #877

This commit is contained in:
Laurent GAY
2025-09-13 19:57:53 +02:00
parent 23cd7333c4
commit 726a5e1d78
8 changed files with 252 additions and 8 deletions

View File

@@ -332,6 +332,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
description: "Filter users by current signed-in IP address"
)
arg(:pending_user, :boolean,
default_value: false,
description: "Filter users by pending or not"
)
arg(:page, :integer, default_value: 1, description: "The page in the paginated users list")
arg(:limit, :integer, default_value: 10, description: "The limit of users per page")

View File

@@ -290,6 +290,7 @@ defmodule Mobilizon.Users do
@spec list_users(Keyword.t()) :: Page.t(User.t())
def list_users(options) do
User
|> filter_by_pending(Keyword.get(options, :pending_user))
|> filter_by_email(Keyword.get(options, :email))
|> filter_by_ip(Keyword.get(options, :current_sign_in_ip))
|> sort(Keyword.get(options, :sort), Keyword.get(options, :direction))
@@ -534,6 +535,11 @@ defmodule Mobilizon.Users do
where(User, [u], u.id == ^user_id)
end
@spec filter_by_pending(Ecto.Queryable.t(), Boolean.t() | nil) :: Ecto.Query.t()
defp filter_by_pending(query, nil), do: query
defp filter_by_pending(query, true), do: where(query, [q], q.role == :pending)
defp filter_by_pending(query, false), do: where(query, [q], q.role != :pending)
@spec filter_by_email(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Query.t()
defp filter_by_email(query, nil), do: query
defp filter_by_email(query, ""), do: query

View File

@@ -271,6 +271,7 @@ export const LIST_USERS = gql`
query ListUsers(
$email: String
$currentSignInIp: String
$pendingUser: Boolean
$page: Int
$limit: Int
$sort: SortableUserField
@@ -279,6 +280,7 @@ export const LIST_USERS = gql`
users(
email: $email
currentSignInIp: $currentSignInIp
pendingUser: $pendingUser
page: $page
limit: $limit
sort: $sort

View File

@@ -854,7 +854,7 @@
"Open user menu": "Open user menu",
"Opened reports": "Opened reports",
"Open": "Open",
"Options":"Options",
"Options": "Options",
"Or": "Or",
"Ordered list": "Ordered list",
"Organized": "Organized",
@@ -1374,6 +1374,7 @@
"User suspended and report resolved": "User suspended and report resolved",
"Username": "Username",
"Users": "Users",
"Users pending for moderation": "Users pending for moderation",
"Validating account": "Validating account",
"Validating email": "Validating email",
"Video Conference": "Video Conference",

View File

@@ -10,8 +10,13 @@
]"
/>
<div v-if="users">
<form @submit.prevent="activateFilters">
<form>
<o-field class="mb-5" grouped group-multiline>
<p class="control mb-3 m-auto" v-if="registrationsModeration">
<o-checkbox v-model="pendingFieldValue">{{
$t("Users pending for moderation")
}}</o-checkbox>
</p>
<o-field :label="$t('Email')" expanded>
<o-input trap-focus icon="email" v-model="emailFilterFieldValue" />
</o-field>
@@ -19,7 +24,7 @@
<o-input icon="web" v-model="ipFilterFieldValue" />
</o-field>
<p class="control self-end mb-0">
<o-button variant="primary" native-type="submit">{{
<o-button variant="primary" @click="filterUsers">{{
$t("Filter")
}}</o-button>
</p>
@@ -112,17 +117,29 @@ import { useI18n } from "vue-i18n";
import { useHead } from "@/utils/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { formatDateTimeString } from "@/filters/datetime";
import { useRegistrationConfig } from "@/composition/apollo/config";
const { registrationsModeration } = useRegistrationConfig();
const USERS_PER_PAGE = 10;
const emailFilter = useRouteQuery("emailFilter", "");
const ipFilter = useRouteQuery("ipFilter", "");
const pendingFilter = useRouteQuery("pendingFilter", "true");
const page = useRouteQuery("page", 1, integerTransformer);
const languagesCodes = computed((): string[] => {
return (users.value?.elements ?? []).map((user: IUser) => user.locale);
});
const pendingFilterBuilder = computed(() => {
if (registrationsModeration.value) {
return pendingFilter.value == "true";
} else {
return false;
}
});
const {
result: usersResult,
fetchMore,
@@ -130,6 +147,7 @@ const {
} = useQuery<{ users: Paginate<IUser> }>(LIST_USERS, () => ({
email: emailFilter.value,
currentSignInIp: ipFilter.value,
pendingUser: pendingFilterBuilder.value,
page: page.value,
limit: USERS_PER_PAGE,
}));
@@ -156,6 +174,7 @@ useHead({
const emailFilterFieldValue = ref(emailFilter.value);
const ipFilterFieldValue = ref(ipFilter.value);
const pendingFieldValue = ref(pendingFilter.value == "true");
const getLanguageNameForCode = (code: string): string => {
return (
@@ -171,20 +190,31 @@ const onPageChange = async (newPage: number): Promise<void> => {
variables: {
email: emailFilter.value,
currentSignInIp: ipFilter.value,
pendingUser: pendingFilterBuilder.value,
page: page.value,
limit: USERS_PER_PAGE,
},
});
};
const activateFilters = (): void => {
const filterUsers = async (): void => {
emailFilter.value = emailFilterFieldValue.value;
ipFilter.value = ipFilterFieldValue.value;
if (registrationsModeration.value) {
if (pendingFieldValue.value) {
pendingFilter.value = "true";
} else {
pendingFilter.value = "false";
}
} else {
pendingFilter.value = "";
}
};
const resetFilters = (): void => {
emailFilterFieldValue.value = "";
ipFilterFieldValue.value = "";
pendingFieldValue.value = true;
activateFilters();
};
</script>

View File

@@ -38,6 +38,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
query ListUsers(
$email: String
$currentSignInIp: String
$pendingUser: Boolean
$page: Int
$limit: Int
$sort: SortableUserField
@@ -46,6 +47,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
users(
email: $email
currentSignInIp: $currentSignInIp
pendingUser: $pendingUser
page: $page
limit: $limit
sort: $sort
@@ -246,6 +248,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
user = insert(:user, email: "riri@example.com", role: :moderator)
insert(:user, email: "fifi@example.com")
insert(:user, email: "loulou@example.com")
insert(:user, email: "picsous@example.com", role: :pending)
res =
conn
@@ -259,6 +262,25 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
assert res["data"]["users"]["total"] == 3
assert res["data"]["users"]["elements"] |> length == 3
assert res["data"]["users"]["elements"]
|> Enum.map(& &1["email"]) == [
"loulou@example.com",
"fifi@example.com",
"riri@example.com"
]
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @list_users_query,
variables: %{pendingUser: false}
)
assert res["errors"] == nil
assert res["data"]["users"]["total"] == 3
assert res["data"]["users"]["elements"] |> length == 3
assert res["data"]["users"]["elements"]
|> Enum.map(& &1["email"]) == [
"loulou@example.com",
@@ -297,6 +319,22 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
assert res["data"]["users"]["elements"] |> Enum.map(& &1["email"]) == [
"riri@example.com"
]
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @list_users_query,
variables: %{pendingUser: true}
)
assert res["errors"] == nil
assert res["data"]["users"]["total"] == 1
assert res["data"]["users"]["elements"] |> length == 1
assert res["data"]["users"]["elements"] |> Enum.map(& &1["email"]) == [
"picsous@example.com"
]
end
test "list_users/3 allows filtering the list of users by email", %{conn: conn} do

View File

@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UsersView > Show simple list 1`] = `
exports[`UsersView > Show list with moderation 1`] = `
"<div data-v-01123d3b="">
<breadcrumbs-nav data-v-01123d3b="" links="[object Object],[object Object]"></breadcrumbs-nav>
<div data-v-01123d3b="">
@@ -9,6 +9,7 @@ exports[`UsersView > Show simple list 1`] = `
<!---->
<div class="o-field__body">
<div class="o-field o-field--grouped-multiline o-field--grouped">
<p data-v-01123d3b="" class="control mb-3 m-auto"><label data-v-01123d3b="" class="o-chk o-chk--checked" data-oruga="checkbox" role="checkbox" aria-checked="true"><input type="checkbox" data-oruga-input="checkbox" class="o-chk__input o-chk__input--checked" autocomplete="off" true-value="true" false-value="false"><span class="o-chk__label">Users pending for moderation</span></label></p>
<div data-v-01123d3b="" data-oruga="field" class="o-field" expanded=""><label class="o-field__label" for="">Email</label>
<div class="o-field__body">
<div class="o-field o-field--addons">
@@ -31,7 +32,121 @@ exports[`UsersView > Show simple list 1`] = `
</div>
<!---->
</div>
<p data-v-01123d3b="" class="control self-end mb-0"><button data-v-01123d3b="" type="submit" class="o-btn o-btn--primary" role="button" data-oruga="button"><span class="o-btn__wrapper"><!----><span class="o-btn__label">Filter</span>
<p data-v-01123d3b="" class="control self-end mb-0"><button data-v-01123d3b="" type="button" class="o-btn o-btn--primary" role="button" data-oruga="button"><span class="o-btn__wrapper"><!----><span class="o-btn__label">Filter</span>
<!----></span>
</button></p>
</div>
</div>
<!---->
</div>
</form>
<div data-v-01123d3b="" class="o-table__root" data-oruga="table">
<div style="display: none;"><span data-v-01123d3b="" data-id="1" data-oruga="table-column"> <!----></span><span data-v-01123d3b="" data-id="2" data-oruga="table-column">Email <!----></span><span data-v-01123d3b="" data-id="3" data-oruga="table-column" centered="true">Last seen on <!----></span><span data-v-01123d3b="" data-id="4" data-oruga="table-column" centered="true">Language <!----></span></div>
<!---->
<!---->
<div class="o-table__wrapper">
<table class="o-table">
<!---->
<thead>
<tr>
<!---->
<!---->
<th class="o-table__th" style="width: 40px;" draggable="false"><span> <span class="o-table__th__sort-icon" style="display: none;"><span class="o-icon o-icon--small" data-oruga="icon"><i class="mdi mdi-arrow-up"></i></span></span></span></th>
<th class="o-table__th" draggable="false"><span>Email <span class="o-table__th__sort-icon" style="display: none;"><span class="o-icon o-icon--small" data-oruga="icon"><i class="mdi mdi-arrow-up"></i></span></span></span></th>
<th class="o-table__th" draggable="false"><span>Last seen on <span class="o-table__th__sort-icon" style="display: none;"><span class="o-icon o-icon--small" data-oruga="icon"><i class="mdi mdi-arrow-up"></i></span></span></span></th>
<th class="o-table__th" draggable="false"><span>Language <span class="o-table__th__sort-icon" style="display: none;"><span class="o-icon o-icon--small" data-oruga="icon"><i class="mdi mdi-arrow-up"></i></span></span></span></th>
<!---->
</tr>
<!---->
<!---->
</thead>
<tbody>
<tr class="" draggable="false">
<!---->
<!---->
<td class="o-table__td" style="width: 40px;">6</td>
<td class="o-table__td" data-label="Email"><a data-v-01123d3b="" href="/settings/admin/users/6" class="">truc@mobilizon.test</a></td>
<td class="o-table__td" data-label="Last seen on"><time data-v-01123d3b="">Thursday, January 1, 1970 at 1:00 AM</time></td>
<td class="o-table__td" data-label="Language">English</td>
<!---->
</tr>
<transition-stub name="slide" appear="false" persisted="false" css="true">
<!---->
</transition-stub>
<!---->
<tr class="" draggable="false">
<!---->
<!---->
<td class="o-table__td" style="width: 40px;">1</td>
<td class="o-table__td" data-label="Email"><a data-v-01123d3b="" href="/settings/admin/users/1" class="">admin@mobilizon.test</a></td>
<td class="o-table__td" data-label="Last seen on"><time data-v-01123d3b="" datetime="2025-09-11T16:10:03Z">Thursday, September 11, 2025 at 6:10 PM</time></td>
<td class="o-table__td" data-label="Language">English</td>
<!---->
</tr>
<transition-stub name="slide" appear="false" persisted="false" css="true">
<!---->
</transition-stub>
<!---->
<!---->
</tbody>
<!---->
</table>
<transition-stub name="fade" appear="false" persisted="false" css="true">
<!---->
</transition-stub>
</div>
<div class="o-table__pagination" per-page="10" total="2" rounded="false" size="small" simple="false" aria-next-label="Next page" aria-previous-label="Previous page" aria-page-label="Page" aria-current-label="Current page">
<div></div>
<div>
<nav class="o-pag o-pag--right o-pag--small" data-oruga="pagination"><button role="button" tabindex="0" class="o-pag__link o-pag__previous o-pag__link--disabled" aria-label="Previous page" aria-current="false"><span class="o-icon" data-oruga="icon" aria-hidden="true"><i class="mdi mdi-chevron-left mdi-24px"></i></span></button><button role="button" tabindex="0" class="o-pag__link o-pag__next o-pag__link--disabled" aria-label="Next page" aria-current="false"><span class="o-icon" data-oruga="icon" aria-hidden="true"><i class="mdi mdi-chevron-right mdi-24px"></i></span></button>
<ul class="o-pag__list">
<!---->
<!---->
<li class="o-pag__item"><button role="button" tabindex="0" class="o-pag__link o-pag__link--current" aria-label="Current page, Page 1." aria-current="true">1</button></li>
<!---->
<!---->
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>"
`;
exports[`UsersView > Show simple list 1`] = `
"<div data-v-01123d3b="">
<breadcrumbs-nav data-v-01123d3b="" links="[object Object],[object Object]"></breadcrumbs-nav>
<div data-v-01123d3b="">
<form data-v-01123d3b="">
<div data-v-01123d3b="" data-oruga="field" class="o-field mb-5">
<!---->
<div class="o-field__body">
<div class="o-field o-field--grouped-multiline o-field--grouped">
<!--v-if-->
<div data-v-01123d3b="" data-oruga="field" class="o-field" expanded=""><label class="o-field__label" for="">Email</label>
<div class="o-field__body">
<div class="o-field o-field--addons">
<div data-v-01123d3b="" data-oruga="input" class="o-input__wrapper"><input trap-focus="" id="" data-oruga-input="text" type="text" class="o-input o-input--iconspace-left" autocomplete="off"><span class="o-icon o-input__icon-left" data-oruga="icon"><i class="mdi mdi-email mdi-24px"></i></span>
<!---->
<!---->
</div>
</div>
</div>
<!---->
</div>
<div data-v-01123d3b="" data-oruga="field" class="o-field" expanded=""><label class="o-field__label" for="">IP Address</label>
<div class="o-field__body">
<div class="o-field o-field--addons">
<div data-v-01123d3b="" data-oruga="input" class="o-input__wrapper"><input id="" data-oruga-input="text" type="text" class="o-input o-input--iconspace-left" autocomplete="off"><span class="o-icon o-input__icon-left" data-oruga="icon"><i class="mdi mdi-web mdi-24px"></i></span>
<!---->
<!---->
</div>
</div>
</div>
<!---->
</div>
<p data-v-01123d3b="" class="control self-end mb-0"><button data-v-01123d3b="" type="button" class="o-btn o-btn--primary" role="button" data-oruga="button"><span class="o-btn__wrapper"><!----><span class="o-btn__label">Filter</span>
<!----></span>
</button></p>
</div>

View File

@@ -16,6 +16,7 @@ import { createRouter, createWebHistory, Router } from "vue-router";
import { routes } from "@/router";
import { Oruga } from "@oruga-ui/oruga-next";
import { htmlRemoveId } from "../../common";
import { CONFIG } from "@/graphql/config";
let router: Router;
@@ -103,15 +104,23 @@ const listUsersMock = {
config.global.plugins.push(Oruga);
const generateWrapper = (currentUsersMock = listUsersMock) => {
const generateWrapper = (currentModeration = false) => {
mockClient = createMockClient({
cache,
resolvers: buildCurrentUserResolver(cache),
});
requestHandlers = {
config: vi.fn().mockResolvedValue({
data: {
config: {
registrationsModeration: currentModeration,
},
},
}),
languagecode: vi.fn().mockResolvedValue(languageCodeMock),
list_users: vi.fn().mockResolvedValue(currentUsersMock),
list_users: vi.fn().mockResolvedValue(listUsersMock),
};
mockClient.setRequestHandler(CONFIG, requestHandlers.config);
mockClient.setRequestHandler(LANGUAGES_CODES, requestHandlers.languagecode);
mockClient.setRequestHandler(LIST_USERS, requestHandlers.list_users);
@@ -145,6 +154,44 @@ describe("UsersView", () => {
expect(wrapper.exists()).toBe(true);
expect(requestHandlers.languagecode).toHaveBeenCalled();
expect(requestHandlers.list_users).toHaveBeenCalled();
expect(requestHandlers.list_users).toHaveBeenCalledWith({
currentSignInIp: "",
email: "",
limit: 10,
page: 1,
pendingUser: false,
});
expect(htmlRemoveId(wrapper.html())).toMatchSnapshot();
});
it("Show list with moderation", async () => {
const wrapper = generateWrapper(true);
await wrapper.vm.$nextTick();
await flushPromises();
expect(wrapper.exists()).toBe(true);
expect(requestHandlers.languagecode).toHaveBeenCalledTimes(0);
expect(requestHandlers.list_users).toHaveBeenCalledTimes(1);
expect(requestHandlers.list_users).toHaveBeenCalledWith({
currentSignInIp: "",
email: "",
limit: 10,
page: 1,
pendingUser: true,
});
expect(htmlRemoveId(wrapper.html())).toMatchSnapshot();
wrapper.vm.pendingFieldValue = false;
//wrapper.find('input[type="checkbox"]').trigger("change");
wrapper.find('input[type="text"]').setValue("@email.tld");
wrapper.find('button[type="button"]').trigger("click");
await flushPromises();
expect(requestHandlers.list_users).toHaveBeenCalledTimes(3);
expect(requestHandlers.list_users).toHaveBeenNthCalledWith(3, {
currentSignInIp: "",
email: "@email.tld",
limit: 10,
page: 1,
pendingUser: false,
});
});
});