From 04cf4efee4b955f2a5ecf20438a0f858c7cc2079 Mon Sep 17 00:00:00 2001 From: Laurent GAY Date: Thu, 11 Sep 2025 20:07:20 +0200 Subject: [PATCH] manage user pending of moderation - #877 --- lib/graphql/schema/user.ex | 1 + lib/mobilizon/users/users.ex | 2 +- lib/web/email/user.ex | 8 +- schema.graphql | 3 + src/graphql/user.ts | 1 + src/i18n/en_US.json | 1 + src/types/enums.ts | 1 + src/views/Admin/UsersView.vue | 3 +- src/views/User/ValidateUser.vue | 26 +-- test/graphql/resolvers/user_test.exs | 47 ++++++ .../components/User/ValidateUser.spec.ts | 118 ++++++++++++++ .../__snapshots__/ValidateUser.spec.ts.snap | 16 ++ .../adminUsersProfileView.spec.ts.snap | 89 +++++++++++ .../admin/adminUsersProfileView.spec.ts | 123 +++++++++++++++ .../specs/components/admin/usersView.spec.ts | 148 ++++++++++++++++++ 15 files changed, 574 insertions(+), 13 deletions(-) create mode 100644 tests/unit/specs/components/User/ValidateUser.spec.ts create mode 100644 tests/unit/specs/components/User/__snapshots__/ValidateUser.spec.ts.snap create mode 100644 tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap create mode 100644 tests/unit/specs/components/admin/adminUsersProfileView.spec.ts create mode 100644 tests/unit/specs/components/admin/usersView.spec.ts diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex index a959f1212..6d428f2e4 100644 --- a/lib/graphql/schema/user.ex +++ b/lib/graphql/schema/user.ex @@ -206,6 +206,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do value(:administrator, description: "Administrator role") value(:moderator, description: "Moderator role") value(:user, description: "User role") + value(:pending, description: "Pending role") end @desc "Token" diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 3881f525b..fe9e9ef87 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -15,7 +15,7 @@ defmodule Mobilizon.Users do alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users.{ActivitySetting, PushSubscription, Setting, User} - defenum(UserRole, :user_role, [:administrator, :moderator, :user]) + defenum(UserRole, :user_role, [:administrator, :moderator, :user, :pending]) defenum(NotificationPendingNotificationDelay, none: 0, diff --git a/lib/web/email/user.ex b/lib/web/email/user.ex index 6967f055b..5f900c7f8 100644 --- a/lib/web/email/user.ex +++ b/lib/web/email/user.ex @@ -70,7 +70,13 @@ defmodule Mobilizon.Web.Email.User do confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second), confirmation_sent_at: nil, confirmation_token: nil, - email: user.unconfirmed_email || user.email + email: user.unconfirmed_email || user.email, + role: + if Config.instance_registrations_moderation?() do + :pending + else + user.role + end }) do {:ok, %User{} = user} -> Logger.info("User #{user.email} has been confirmed") diff --git a/schema.graphql b/schema.graphql index 0c6395622..99a0b0324 100644 --- a/schema.graphql +++ b/schema.graphql @@ -315,6 +315,9 @@ enum UserRole { "User role" USER + + "Pending role" + PENDING } """ diff --git a/src/graphql/user.ts b/src/graphql/user.ts index 9de34aa8e..5b7aa4bbf 100644 --- a/src/graphql/user.ts +++ b/src/graphql/user.ts @@ -28,6 +28,7 @@ export const VALIDATE_USER = gql` user { id email + role defaultActor { ...ActorFragment } diff --git a/src/i18n/en_US.json b/src/i18n/en_US.json index 3ab645f21..c0b3ca552 100644 --- a/src/i18n/en_US.json +++ b/src/i18n/en_US.json @@ -25,6 +25,7 @@ "A link to a page presenting the price options": "A link to a page presenting the price options", "A member has been updated": "A member has been updated", "A member requested to join one of my groups": "A member requested to join one of my groups", + "A moderator will take care of your request.": "A moderator will take care of your request.", "A new version is available.": "A new version is available.", "A place for your code of conduct, rules or guidelines. You can use HTML tags.": "A place for your code of conduct, rules or guidelines. You can use HTML tags.", "A place to explain who you are and the things that set your instance apart. You can use HTML tags.": "A place to explain who you are and the things that set your instance apart. You can use HTML tags.", diff --git a/src/types/enums.ts b/src/types/enums.ts index 8d9fee2c7..dfc68bd9c 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -21,6 +21,7 @@ export enum ICurrentUserRole { USER = "USER", MODERATOR = "MODERATOR", ADMINISTRATOR = "ADMINISTRATOR", + PENDING = "PENDING", } export enum INotificationPendingEnum { diff --git a/src/views/Admin/UsersView.vue b/src/views/Admin/UsersView.vue index bfd72b7c0..0dab455fe 100644 --- a/src/views/Admin/UsersView.vue +++ b/src/views/Admin/UsersView.vue @@ -63,12 +63,11 @@ :centered="true" v-slot="props" > - @@ -45,6 +48,7 @@ const props = defineProps<{ const loading = ref(true); const failed = ref(false); +const moderated = ref(false); onBeforeMount(() => { validateAction({ token: props.token }); @@ -79,18 +83,22 @@ onUpdatingCurrentUserClientDone(async () => { onValidatingUserMutationDone(async ({ data }) => { if (data) { - saveUserData(data.validateUser); - saveTokenData(data.validateUser); - const { user: validatedUser } = data.validateUser; user.value = validatedUser; - updateCurrentUserClient({ - id: validatedUser.id, - email: validatedUser.email, - isLoggedIn: true, - role: ICurrentUserRole.USER, - }); + if (validatedUser.role != ICurrentUserRole.PENDING) { + saveUserData(data.validateUser); + saveTokenData(data.validateUser); + await updateCurrentUserClient({ + id: validatedUser.id, + email: validatedUser.email, + isLoggedIn: true, + role: validatedUser.role, + }); + } else { + moderated.value = true; + loading.value = false; + } } }); diff --git a/test/graphql/resolvers/user_test.exs b/test/graphql/resolvers/user_test.exs index 951e685d0..e5f67aa22 100644 --- a/test/graphql/resolvers/user_test.exs +++ b/test/graphql/resolvers/user_test.exs @@ -654,6 +654,9 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do describe "Resolver: Validate an user" do test "test validate_user/3 validates an user", context do + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :registrations_moderation], false) + {:ok, %User{} = user} = Users.register(@valid_actor_params) mutation = """ @@ -664,6 +667,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do accessToken, user { id, + role, }, } } @@ -674,9 +678,50 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) assert json_response(res, 200)["data"]["validateUser"]["user"]["id"] == to_string(user.id) + assert json_response(res, 200)["data"]["validateUser"]["user"]["role"] == "USER" + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :registrations_moderation], false) + end + + test "test validate_user/3 validates an user with moderation", context do + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :registrations_moderation], true) + + user = + insert(:user, + email: "test@test.tld", + password: "testest", + moderation: "moderation text", + confirmation_token: "t0t0" + ) + + mutation = """ + mutation { + validateUser( + token: "#{user.confirmation_token}" + ) { + accessToken, + user { + id, + role, + }, + } + } + """ + + res = + context.conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["data"]["validateUser"]["user"]["id"] == to_string(user.id) + assert json_response(res, 200)["data"]["validateUser"]["user"]["role"] == "PENDING" + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :registrations_moderation], false) end test "test validate_user/3 with invalid token doesn't validate an user", context do + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :registrations_moderation], false) insert(:user, confirmation_token: "t0t0") mutation = """ @@ -697,6 +742,8 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) assert hd(json_response(res, 200)["errors"])["message"] == "Unable to validate user" + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :registrations_moderation], false) end end diff --git a/tests/unit/specs/components/User/ValidateUser.spec.ts b/tests/unit/specs/components/User/ValidateUser.spec.ts new file mode 100644 index 000000000..aba5b6e7e --- /dev/null +++ b/tests/unit/specs/components/User/ValidateUser.spec.ts @@ -0,0 +1,118 @@ +import { config, mount } from "@vue/test-utils"; +import ValidateUser from "@/views/User/ValidateUser.vue"; +import { createMockClient, RequestHandler } from "mock-apollo-client"; +import flushPromises from "flush-promises"; +import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT } from "@/graphql/user"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DefaultApolloClient } from "@vue/apollo-composable"; +import Oruga from "@oruga-ui/oruga-next"; +import { + VueRouterMock, + createRouterMock, + injectRouterMock, +} from "vue-router-mock"; +import { nullMock } from "../../common"; +import * as auth_mod from "@/utils/auth.ts"; + +config.global.plugins.push(Oruga); +config.plugins.VueWrapper.install(VueRouterMock); + +vi.spyOn(auth_mod, "saveTokenData"); +vi.spyOn(auth_mod, "saveUserData"); + +let requestHandlers: Record; + +const validateUserMock = { + data: { + validateUser: { + accessToken: "aaaaaaa", + refreshToken: "zzzzzzz", + user: { + id: "123", + email: "truc@machin.com", + role: "USER", + }, + }, + }, +}; + +const generateWrapper = (moderate: boolean = false) => { + const mockClient = createMockClient(); + const validate_user = { + ...validateUserMock, + }; + if (moderate) { + validate_user.data.validateUser.user.role = "PENDING"; + } + requestHandlers = { + validateUserHandler: vi.fn().mockResolvedValue(validateUserMock), + updateUserHandler: vi.fn().mockResolvedValue(nullMock), + }; + + mockClient.setRequestHandler( + VALIDATE_USER, + requestHandlers.validateUserHandler + ); + mockClient.setRequestHandler( + UPDATE_CURRENT_USER_CLIENT, + requestHandlers.updateUserHandler + ); + + const wrapper = mount(ValidateUser, { + props: { + token: "123456789", + }, + global: { + stubs: ["router-link", "router-view"], + provide: { + [DefaultApolloClient]: mockClient, + }, + }, + }); + wrapper.router.push.mockReset(); + return wrapper; +}; + +describe("Validate user page", () => { + const router = createRouterMock({ + spy: { + create: (fn) => vi.fn(fn), + reset: (spy) => spy.mockReset(), + }, + }); + beforeEach(() => { + // inject it globally to ensure `useRoute()`, `$route`, etc work + // properly and give you access to test specific functions + injectRouterMock(router); + }); + + it("simple", async () => { + const wrapper = generateWrapper(); + expect(wrapper.router).toBe(router); + await flushPromises(); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + expect(wrapper.html()).toMatchSnapshot(); + expect(requestHandlers.validateUserHandler).toBeCalledTimes(1); + expect(requestHandlers.validateUserHandler).toHaveBeenCalledWith({ + token: "123456789", + }); + // expect(wrapper.router.replace).toHaveBeenCalledWith({ + // name: RouteName.CREATE_IDENTITY, + // }); + // expect(requestHandlers.updateUserHandler).toBeCalledTimes(1); + }); + + it("moderate", async () => { + const wrapper = generateWrapper(true); + expect(wrapper.router).toBe(router); + await flushPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.html()).toMatchSnapshot(); + expect(requestHandlers.validateUserHandler).toBeCalledTimes(1); + expect(requestHandlers.validateUserHandler).toHaveBeenCalledWith({ + token: "123456789", + }); + expect(requestHandlers.updateUserHandler).toBeCalledTimes(0); + }); +}); diff --git a/tests/unit/specs/components/User/__snapshots__/ValidateUser.spec.ts.snap b/tests/unit/specs/components/User/__snapshots__/ValidateUser.spec.ts.snap new file mode 100644 index 000000000..b862a4684 --- /dev/null +++ b/tests/unit/specs/components/User/__snapshots__/ValidateUser.spec.ts.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Validate user page > moderate 1`] = ` +"
+
+

Your account has been validated

+

A moderator will take care of your request.

+
+
" +`; + +exports[`Validate user page > simple 1`] = ` +"
+

Your account is being validated

+
" +`; diff --git a/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap b/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap new file mode 100644 index 000000000..4e0d38a10 --- /dev/null +++ b/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap @@ -0,0 +1,89 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UsersView > Show simple list 1`] = ` +"
+ +
+

Details

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Emailtruc@mobilizon.test + + +
LanguageFrench
RoleUser + +
Login statusActivated
ConfirmedSaturday, August 30, 2025 at 11:56 AM + +
Last sign-inUnknown
Last IP adressUnknown
Total number of participations14
Uploaded media total size6,76 mégaoctets
+
+
+
+
+
+
+

Profiles

+
+ +
+
+
+

Actions

+
+ +
+
+ + + +
" +`; diff --git a/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts b/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts new file mode 100644 index 000000000..1d42349c6 --- /dev/null +++ b/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DefaultApolloClient } from "@vue/apollo-composable"; +import { config, shallowMount } from "@vue/test-utils"; +import buildCurrentUserResolver from "@/apollo/user"; +import flushPromises from "flush-promises"; +import { cache } from "@/apollo/memory"; +import { + createMockClient, + MockApolloClient, + RequestHandler, +} from "mock-apollo-client"; +import AdminUserProfile from "@/views/Admin/AdminUserProfile.vue"; +import { GET_USER } from "@/graphql/user"; +import { LANGUAGES_CODES } from "@/graphql/admin"; +import { createRouter, createWebHistory, Router } from "vue-router"; +import { routes } from "@/router"; +import { Oruga } from "@oruga-ui/oruga-next"; + +let router: Router; + +let mockClient: MockApolloClient | null; +let requestHandlers: Record; + +const languageCodeMock = { + data: { + languages: [ + { + __typename: "Language", + code: "fr", + name: "French", + }, + { + __typename: "Language", + code: "en", + name: "English", + }, + ], + }, +}; + +const getUsersMock = { + data: { + user: { + __typename: "User", + actors: [ + { + __typename: "Person", + avatar: null, + domain: null, + id: "11371", + name: "Truc", + preferredUsername: "truc", + summary: null, + type: "PERSON", + url: "http://mobilizon.test/@truc", + }, + ], + confirmedAt: "2025-08-30T09:56:59Z", + currentSignInAt: null, + currentSignInIp: null, + disabled: false, + email: "truc@mobilizon.test", + id: "1234", + lastSignInAt: "2025-08-28T12:33:03Z", + lastSignInIp: "176.171.166.30", + locale: "fr", + mediaSize: 7093555, + participations: { + __typename: "PaginatedParticipantList", + total: 14, + }, + role: "USER", + }, + }, +}; + +config.global.plugins.push(Oruga); + +const generateWrapper = () => { + mockClient = createMockClient({ + cache, + resolvers: buildCurrentUserResolver(cache), + }); + requestHandlers = { + languagecode: vi.fn().mockResolvedValue(languageCodeMock), + get_users: vi.fn().mockResolvedValue(getUsersMock), + }; + mockClient.setRequestHandler(LANGUAGES_CODES, requestHandlers.languagecode); + mockClient.setRequestHandler(GET_USER, requestHandlers.get_users); + + const wrapper = shallowMount(AdminUserProfile, { + props: { id: "1234" }, + stubs: ["router-link", "router-view"], + global: { + provide: { + [DefaultApolloClient]: mockClient, + }, + plugins: [router], + }, + }); + return wrapper; +}; + +describe("UsersView", () => { + beforeEach(async () => { + router = createRouter({ + history: createWebHistory(), + routes: routes, + }); + + // await router.isReady(); + }); + + it("Show simple list", async () => { + const wrapper = generateWrapper(); + await wrapper.vm.$nextTick(); + await flushPromises(); + expect(wrapper.exists()).toBe(true); + expect(requestHandlers.languagecode).toHaveBeenCalled(); + expect(requestHandlers.get_users).toHaveBeenCalled(); + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/tests/unit/specs/components/admin/usersView.spec.ts b/tests/unit/specs/components/admin/usersView.spec.ts new file mode 100644 index 000000000..e6e66f949 --- /dev/null +++ b/tests/unit/specs/components/admin/usersView.spec.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DefaultApolloClient } from "@vue/apollo-composable"; +import { config, mount } from "@vue/test-utils"; +import buildCurrentUserResolver from "@/apollo/user"; +import flushPromises from "flush-promises"; +import { cache } from "@/apollo/memory"; +import { + createMockClient, + MockApolloClient, + RequestHandler, +} from "mock-apollo-client"; +import UsersView from "@/views/Admin/UsersView.vue"; +import { LIST_USERS } from "@/graphql/user"; +import { LANGUAGES_CODES } from "@/graphql/admin"; +import { createRouter, createWebHistory, Router } from "vue-router"; +import { routes } from "@/router"; +import { Oruga } from "@oruga-ui/oruga-next"; + +let router: Router; + +let mockClient: MockApolloClient | null; +let requestHandlers: Record; + +const languageCodeMock = { + data: { + languages: [ + { + __typename: "Language", + code: "fr", + name: "French", + }, + { + __typename: "Language", + code: "en", + name: "English", + }, + ], + }, +}; + +const listUsersMock = { + data: { + users: { + __typename: "Users", + elements: [ + { + __typename: "User", + actors: [ + { + __typename: "Person", + avatar: null, + domain: null, + id: "11371", + name: "Truc", + preferredUsername: "truc", + summary: null, + type: "PERSON", + url: "http://mobilizon.test/@truc", + }, + ], + confirmedAt: "2025-08-30T09:56:59Z", + currentSignInAt: null, + currentSignInIp: null, + disabled: false, + email: "truc@mobilizon.test", + id: "6", + locale: "en", + settings: null, + }, + { + __typename: "User", + actors: [ + { + __typename: "Person", + avatar: null, + domain: null, + id: "1", + name: "Administrator", + preferredUsername: "administrator", + summary: null, + type: "PERSON", + url: "https://mobilizon.test/@administrator", + }, + ], + confirmedAt: "2025-06-04T16:19:48Z", + currentSignInAt: "2025-09-11T16:10:03Z", + currentSignInIp: "127.0.0.1", + disabled: false, + email: "admin@mobilizon.test", + id: "1", + locale: "en", + settings: { + __typename: "UserSettings", + timezone: "Europe/Paris", + }, + }, + ], + total: 2, + }, + }, +}; + +config.global.plugins.push(Oruga); + +const generateWrapper = () => { + mockClient = createMockClient({ + cache, + resolvers: buildCurrentUserResolver(cache), + }); + requestHandlers = { + languagecode: vi.fn().mockResolvedValue(languageCodeMock), + list_users: vi.fn().mockResolvedValue(listUsersMock), + }; + mockClient.setRequestHandler(LANGUAGES_CODES, requestHandlers.languagecode); + mockClient.setRequestHandler(LIST_USERS, requestHandlers.list_users); + + const wrapper = mount(UsersView, { + props: {}, + stubs: ["router-link", "router-view"], + global: { + provide: { + [DefaultApolloClient]: mockClient, + }, + plugins: [router], + }, + }); + return wrapper; +}; + +describe("UsersView", () => { + beforeEach(async () => { + router = createRouter({ + history: createWebHistory(), + routes: routes, + }); + + // await router.isReady(); + }); + + it("Show simple list", async () => { + const wrapper = generateWrapper(); + await wrapper.vm.$nextTick(); + await flushPromises(); + expect(wrapper.exists()).toBe(true); + expect(requestHandlers.languagecode).toHaveBeenCalled(); + expect(requestHandlers.list_users).toHaveBeenCalled(); + }); +});