From e3f3ccd1488fa6bc8f085a57dd5f0cbbb6b71c9e Mon Sep 17 00:00:00 2001 From: Laurent GAY Date: Fri, 12 Sep 2025 23:04:12 +0200 Subject: [PATCH] Administration of users with pending - #877 --- lib/graphql/authorization.ex | 2 +- lib/graphql/resolvers/user.ex | 25 ++- src/graphql/user.ts | 1 + src/views/Admin/AdminUserProfile.vue | 104 +++++++++--- test/graphql/resolvers/user_test.exs | 2 + tests/unit/specs/common.ts | 6 + .../adminUsersProfileView.spec.ts.snap | 97 ++++++++++- .../__snapshots__/usersView.spec.ts.snap | 114 +++++++++++++ .../admin/adminUsersProfileView.spec.ts | 156 +++++++++++++----- .../specs/components/admin/usersView.spec.ts | 6 +- 10 files changed, 434 insertions(+), 79 deletions(-) create mode 100644 tests/unit/specs/components/admin/__snapshots__/usersView.spec.ts.snap diff --git a/lib/graphql/authorization.ex b/lib/graphql/authorization.ex index 2bc333432..f25269286 100644 --- a/lib/graphql/authorization.ex +++ b/lib/graphql/authorization.ex @@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Authorization do """ use Rajska, - valid_roles: [:user, :moderator, :administrator], + valid_roles: [:user, :moderator, :administrator, :pending], super_role: :administrator, default_rule: :default diff --git a/lib/graphql/resolvers/user.ex b/lib/graphql/resolvers/user.ex index 15ce54e63..29503c3a5 100644 --- a/lib/graphql/resolvers/user.ex +++ b/lib/graphql/resolvers/user.ex @@ -279,15 +279,24 @@ defmodule Mobilizon.GraphQL.Resolvers.User do {:ok, %User{} = user} -> actor = Users.get_actor_for_user(user) - {:ok, %{access_token: access_token, refresh_token: refresh_token}} = - Authenticator.generate_tokens(user) + if Config.instance_registrations_moderation?() do + {:ok, + %{ + access_token: "", + refresh_token: "", + user: Map.put(user, :default_actor, actor) + }} + else + {:ok, %{access_token: access_token, refresh_token: refresh_token}} = + Authenticator.generate_tokens(user) - {:ok, - %{ - access_token: access_token, - refresh_token: refresh_token, - user: Map.put(user, :default_actor, actor) - }} + {:ok, + %{ + access_token: access_token, + refresh_token: refresh_token, + user: Map.put(user, :default_actor, actor) + }} + end {:error, :invalid_token} -> Logger.info("Invalid token #{token} to validate user") diff --git a/src/graphql/user.ts b/src/graphql/user.ts index 5b7aa4bbf..bcdb2e46d 100644 --- a/src/graphql/user.ts +++ b/src/graphql/user.ts @@ -310,6 +310,7 @@ export const GET_USER = gql` user(id: $id) { id email + moderation confirmedAt confirmationSentAt lastSignInAt diff --git a/src/views/Admin/AdminUserProfile.vue b/src/views/Admin/AdminUserProfile.vue index dfeb7969c..37b9549ae 100644 --- a/src/views/Admin/AdminUserProfile.vue +++ b/src/views/Admin/AdminUserProfile.vue @@ -52,6 +52,8 @@ user.role == ICurrentUserRole.MODERATOR, 'bg-blue-100 text-blue-800': user.role == ICurrentUserRole.USER, + 'bg-orange-100 text-orange-800': + user.role == ICurrentUserRole.PENDING, }" class="text-sm font-medium mr-2 px-2.5 py-0.5 rounded" > @@ -67,7 +69,10 @@ > -
+

{{ t("Profiles") }}

{{ t("Actions") }}

-
- {{ - t("Suspend") - }} -
- + + + + + +
+
+ {{ + t("Accept") + }} +
+
+
+ {{ + t("Suspend") + }} +
+ +
=> { if (!user.value) return []; - return [ + let fields = [ { key: t("Email"), value: user.value.email, @@ -394,10 +415,19 @@ const metadata = computed( value: roleName(user.value.role), type: "role", }, - { + ]; + if (user.value.role == ICurrentUserRole.PENDING) { + fields.push({ + key: t("Moderation"), + value: user.value.moderation, + }); + } else { + fields.push({ key: t("Login status"), value: user.value.disabled ? t("Disabled") : t("Activated"), - }, + }); + } + fields = fields.concat([ { key: t("Confirmed"), value: user.value.confirmedAt @@ -416,15 +446,20 @@ const metadata = computed( 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), - }, - ]; + ]); + if (user.value.role != ICurrentUserRole.PENDING) { + fields = fields.concat([ + { + key: t("Total number of participations"), + value: user.value.participations.total.toString(), + }, + { + key: t("Uploaded media total size"), + value: formatBytes(user.value.mediaSize), + }, + ]); + } + return fields; } ); @@ -434,6 +469,8 @@ const roleName = (role: ICurrentUserRole): string => { return t("Administrator"); case ICurrentUserRole.MODERATOR: return t("Moderator"); + case ICurrentUserRole.PENDING: + return t("Pending"); case ICurrentUserRole.USER: default: return t("User"); @@ -467,6 +504,16 @@ const suspendAccount = async (): Promise => { }); }; +const acceptAccount = async () => { + isRoleChangeModalActive.value = false; + await updateUser({ + id: props.id, + role: ICurrentUserRole.USER, + notify: true, + }); + router.push({ name: RouteName.ADMIN_USER_PROFILE, id: props.id }); +}; + const profiles = computed((): IPerson[] | undefined => { return user.value?.actors; }); @@ -478,6 +525,7 @@ const confirmUser = async () => { confirmed: true, notify: newUser.notify, }); + router.push({ name: RouteName.ADMIN_USER_PROFILE, id: props.id }); }; const updateUserRole = async () => { @@ -487,6 +535,7 @@ const updateUserRole = async () => { role: newUser.role, notify: newUser.notify, }); + router.push({ name: RouteName.ADMIN_USER_PROFILE, id: props.id }); }; const updateUserEmail = async () => { @@ -496,6 +545,7 @@ const updateUserEmail = async () => { email: newUser.email, notify: newUser.notify, }); + router.push({ name: RouteName.ADMIN_USER_PROFILE, id: props.id }); }; const { mutate: updateUser } = useMutation< diff --git a/test/graphql/resolvers/user_test.exs b/test/graphql/resolvers/user_test.exs index e5f67aa22..8e468c88d 100644 --- a/test/graphql/resolvers/user_test.exs +++ b/test/graphql/resolvers/user_test.exs @@ -679,6 +679,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do assert json_response(res, 200)["data"]["validateUser"]["user"]["id"] == to_string(user.id) assert json_response(res, 200)["data"]["validateUser"]["user"]["role"] == "USER" + assert json_response(res, 200)["data"]["validateUser"]["accessToken"] != "" Config.put([:instance, :registrations_open], true) Config.put([:instance, :registrations_moderation], false) end @@ -715,6 +716,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do assert json_response(res, 200)["data"]["validateUser"]["user"]["id"] == to_string(user.id) assert json_response(res, 200)["data"]["validateUser"]["user"]["role"] == "PENDING" + assert json_response(res, 200)["data"]["validateUser"]["accessToken"] == "" Config.put([:instance, :registrations_open], true) Config.put([:instance, :registrations_moderation], false) end diff --git a/tests/unit/specs/common.ts b/tests/unit/specs/common.ts index a15b0ea7a..03c4853aa 100644 --- a/tests/unit/specs/common.ts +++ b/tests/unit/specs/common.ts @@ -68,6 +68,12 @@ export function defaultResolvers( } satisfies Resolvers; } +export function htmlRemoveId(htmlText: string) { + return htmlText + .replaceAll(/ id="[a-z0-9]+" /gi, ' id="" ') + .replaceAll(/ for="[a-z0-9]+"/gi, ' for=""'); +} + export const nullMock = { data: {}, }; diff --git a/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap b/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap index 4e0d38a10..f5f0e4eb4 100644 --- a/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap +++ b/tests/unit/specs/components/admin/__snapshots__/adminUsersProfileView.spec.ts.snap @@ -1,5 +1,89 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`UsersView > Show moderate list 1`] = ` +"
+ +
+

Details

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Emailtruc@mobilizon.test + + +
LanguageFrench
RolePending + +
Moderationmoderation text
ConfirmedSaturday, August 30, 2025 at 11:56 AM + +
Last sign-inUnknown
Last IP adressUnknown
+
+
+
+
+
+ +
+

Actions

+ + + + + +
+
+ +
+
+
+ +
+
+
+ + + +
" +`; + exports[`UsersView > Show simple list 1`] = ` "
@@ -78,9 +162,16 @@ exports[`UsersView > Show simple list 1`] = `

Actions

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

+
+
+ +
+
+
+
Email Last seen on Language
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Email Last seen on Language
6truc@mobilizon.testEnglish
1admin@mobilizon.testEnglish
+ + + +
+
+
+
+ +
+
+
+
+
" +`; diff --git a/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts b/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts index 1d42349c6..c2740956c 100644 --- a/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts +++ b/tests/unit/specs/components/admin/adminUsersProfileView.spec.ts @@ -11,12 +11,15 @@ import { } 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 { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin"; import { Oruga } from "@oruga-ui/oruga-next"; - -let router: Router; +import { nullMock } from "../../common"; +import { + createRouterMock, + injectRouterMock, + VueRouterMock, +} from "vue-router-mock"; +import { SettingsRouteName } from "@/router/settings"; let mockClient: MockApolloClient | null; let requestHandlers: Record; @@ -38,7 +41,7 @@ const languageCodeMock = { }, }; -const getUsersMock = { +const getUserMock = { data: { user: { __typename: "User", @@ -55,6 +58,7 @@ const getUsersMock = { url: "http://mobilizon.test/@truc", }, ], + moderation: "moderation text", confirmedAt: "2025-08-30T09:56:59Z", currentSignInAt: null, currentSignInIp: null, @@ -74,50 +78,126 @@ const getUsersMock = { }, }; -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, +const getModerateMock = { + 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", + }, + ], + moderation: "moderation text", + 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, }, - plugins: [router], + role: "PENDING", }, - }); - return wrapper; + }, }; -describe("UsersView", () => { - beforeEach(async () => { - router = createRouter({ - history: createWebHistory(), - routes: routes, - }); +config.global.plugins.push(Oruga); +config.plugins.VueWrapper.install(VueRouterMock); - // await router.isReady(); +describe("UsersView", () => { + const router = createRouterMock({ + spy: { + create: (fn) => vi.fn(fn), + reset: (spy) => spy.mockReset(), + }, }); + beforeEach(async () => { + // await router.isReady(); + injectRouterMock(router); + }); + + const generateWrapper = (currentUserMock = getUserMock) => { + mockClient = createMockClient({ + cache, + resolvers: buildCurrentUserResolver(cache), + }); + requestHandlers = { + languagecode: vi.fn().mockResolvedValue(languageCodeMock), + get_users: vi.fn().mockResolvedValue(currentUserMock), + update_user: vi.fn().mockResolvedValue(nullMock), + }; + mockClient.setRequestHandler(LANGUAGES_CODES, requestHandlers.languagecode); + mockClient.setRequestHandler(GET_USER, requestHandlers.get_users); + mockClient.setRequestHandler( + ADMIN_UPDATE_USER, + requestHandlers.update_user + ); + + const wrapper = shallowMount(AdminUserProfile, { + props: { id: "1234" }, + stubs: ["router-link", "router-view"], + global: { + provide: { + [DefaultApolloClient]: mockClient, + }, + }, + }); + return wrapper; + }; 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(); + expect(requestHandlers.languagecode).toHaveBeenCalledTimes(1); + expect(requestHandlers.get_users).toHaveBeenCalledTimes(1); + expect(requestHandlers.update_user).toHaveBeenCalledTimes(0); + }); + + it("Show moderate list", async () => { + const wrapper = generateWrapper(getModerateMock); + expect(wrapper.router).toBe(router); + wrapper.router.push.mockReset(); + await wrapper.vm.$nextTick(); + await flushPromises(); + expect(wrapper.exists()).toBe(true); + expect(wrapper.html()).toMatchSnapshot(); + expect(requestHandlers.languagecode).toHaveBeenCalledTimes(0); + expect(requestHandlers.get_users).toHaveBeenCalledTimes(1); + expect(requestHandlers.update_user).toHaveBeenCalledTimes(0); + const btn = wrapper.find('o-button-stub[variant="success"]'); + expect(btn.exists()).toBe(true); + btn.trigger("click"); + await flushPromises(); + expect(requestHandlers.languagecode).toHaveBeenCalledTimes(0); + expect(requestHandlers.get_users).toHaveBeenCalledTimes(1); + expect(requestHandlers.update_user).toHaveBeenCalledTimes(1); + expect(requestHandlers.update_user).toHaveBeenCalledWith({ + id: "1234", + notify: true, + role: "USER", + }); + await flushPromises(); + await flushPromises(); + expect(wrapper.router.push).toHaveBeenCalledWith({ + name: SettingsRouteName.ADMIN_USER_PROFILE, + id: "1234", + }); }); }); diff --git a/tests/unit/specs/components/admin/usersView.spec.ts b/tests/unit/specs/components/admin/usersView.spec.ts index e6e66f949..e66dcfb11 100644 --- a/tests/unit/specs/components/admin/usersView.spec.ts +++ b/tests/unit/specs/components/admin/usersView.spec.ts @@ -15,6 +15,7 @@ import { LANGUAGES_CODES } from "@/graphql/admin"; import { createRouter, createWebHistory, Router } from "vue-router"; import { routes } from "@/router"; import { Oruga } from "@oruga-ui/oruga-next"; +import { htmlRemoveId } from "../../common"; let router: Router; @@ -102,14 +103,14 @@ const listUsersMock = { config.global.plugins.push(Oruga); -const generateWrapper = () => { +const generateWrapper = (currentUsersMock = listUsersMock) => { mockClient = createMockClient({ cache, resolvers: buildCurrentUserResolver(cache), }); requestHandlers = { languagecode: vi.fn().mockResolvedValue(languageCodeMock), - list_users: vi.fn().mockResolvedValue(listUsersMock), + list_users: vi.fn().mockResolvedValue(currentUsersMock), }; mockClient.setRequestHandler(LANGUAGES_CODES, requestHandlers.languagecode); mockClient.setRequestHandler(LIST_USERS, requestHandlers.list_users); @@ -144,5 +145,6 @@ describe("UsersView", () => { expect(wrapper.exists()).toBe(true); expect(requestHandlers.languagecode).toHaveBeenCalled(); expect(requestHandlers.list_users).toHaveBeenCalled(); + expect(htmlRemoveId(wrapper.html())).toMatchSnapshot(); }); });