user list for moderation of pending users - #877
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user