manage user pending of moderation - #877

This commit is contained in:
Laurent GAY
2025-09-11 20:07:20 +02:00
parent fbf22a83b2
commit 04cf4efee4
15 changed files with 574 additions and 13 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -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")

View File

@@ -315,6 +315,9 @@ enum UserRole {
"User role"
USER
"Pending role"
PENDING
}
"""

View File

@@ -28,6 +28,7 @@ export const VALIDATE_USER = gql`
user {
id
email
role
defaultActor {
...ActorFragment
}

View File

@@ -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.",

View File

@@ -21,6 +21,7 @@ export enum ICurrentUserRole {
USER = "USER",
MODERATOR = "MODERATOR",
ADMINISTRATOR = "ADMINISTRATOR",
PENDING = "PENDING",
}
export enum INotificationPendingEnum {

View File

@@ -63,12 +63,11 @@
:centered="true"
v-slot="props"
>
<template v-if="props.row.currentSignInAt">
<template v-if="props.row.confirmedAt">
<time :datetime="props.row.currentSignInAt">
{{ formatDateTimeString(props.row.currentSignInAt) }}
</time>
</template>
<template v-else-if="props.row.confirmedAt"> - </template>
<template v-else>
{{ $t("Not confirmed") }}
</template>

View File

@@ -17,6 +17,9 @@
</o-notification>
</div>
<h1 class="title" v-else>{{ $t("Your account has been validated") }}</h1>
<h2 class="title" v-if="moderated">
{{ $t("A moderator will take care of your request.") }}
</h2>
</div>
</section>
</template>
@@ -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({
if (validatedUser.role != ICurrentUserRole.PENDING) {
saveUserData(data.validateUser);
saveTokenData(data.validateUser);
await updateCurrentUserClient({
id: validatedUser.id,
email: validatedUser.email,
isLoggedIn: true,
role: ICurrentUserRole.USER,
role: validatedUser.role,
});
} else {
moderated.value = true;
loading.value = false;
}
}
});

View File

@@ -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

View File

@@ -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<string, RequestHandler>;
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);
});
});

View File

@@ -0,0 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Validate user page > moderate 1`] = `
"<section class="container mx-auto">
<div>
<h1 class="title">Your account has been validated</h1>
<h2 class="title">A moderator will take care of your request.</h2>
</div>
</section>"
`;
exports[`Validate user page > simple 1`] = `
"<section class="container mx-auto">
<h1 class="title">Your account is being validated</h1>
</section>"
`;

View File

@@ -0,0 +1,89 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UsersView > Show simple list 1`] = `
"<div>
<breadcrumbs-nav links="[object Object],[object Object],[object Object]"></breadcrumbs-nav>
<section>
<h2 class="text-lg font-bold mb-3">Details</h2>
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block py-2 min-w-full sm:px-2">
<div class="overflow-hidden shadow-md sm:rounded-lg">
<table class="table w-full">
<tbody>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Email</td>
<td class="py-4 px-2 align-middle">truc@mobilizon.test</td>
<td class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start gap-2">
<o-button-stub tag="button" variant="text" size="small" iconleft="pencil" rounded="false" expanded="false" disabled="false" outlined="false" loading="false" inverted="false" nativetype="button" role="button" iconboth="false"></o-button-stub>
<o-button-stub tag="router-link" variant="text" size="small" iconleft="magnify" rounded="false" expanded="false" disabled="false" outlined="false" loading="false" inverted="false" nativetype="button" role="button" iconboth="false" to="[object Object]"></o-button-stub>
</td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Language</td>
<td class="py-4 px-2 align-middle">French</td>
<td></td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Role</td>
<td class="py-4 px-2 whitespace-nowrap"><span class="bg-blue-100 text-blue-800 text-sm font-medium mr-2 px-2.5 py-0.5 rounded">User</span></td>
<td class="py-4 px-2 whitespace-nowrap flex items-center">
<o-button-stub tag="button" variant="text" size="small" iconleft="chevron-double-up" rounded="false" expanded="false" disabled="false" outlined="false" loading="false" inverted="false" nativetype="button" role="button" iconboth="false"></o-button-stub>
</td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Login status</td>
<td class="py-4 px-2 align-middle">Activated</td>
<td></td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Confirmed</td>
<td class="py-4 px-2 align-middle">Saturday, August 30, 2025 at 11:56 AM</td>
<td class="py-4 px-2 whitespace-nowrap flex items-center">
<!--v-if-->
</td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Last sign-in</td>
<td class="py-4 px-2 align-middle">Unknown</td>
<td></td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Last IP adress</td>
<td class="py-4 px-2 align-middle">Unknown</td>
<td></td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Total number of participations</td>
<td class="py-4 px-2 align-middle">14</td>
<td></td>
</tr>
<tr class="border-b">
<td class="py-4 px-2 whitespace-nowrap align-middle">Uploaded media total size</td>
<td class="py-4 px-2 align-middle">6,76&nbsp;mégaoctets</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">Profiles</h2>
<div class="flex flex-wrap justify-center sm:justify-start gap-4">
<router-link-stub to="[object Object]" replace="false" custom="false" ariacurrentvalue="page" viewtransition="false"></router-link-stub>
</div>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">Actions</h2>
<div class="buttons">
<o-button-stub tag="button" variant="danger" rounded="false" expanded="false" disabled="false" outlined="false" loading="false" inverted="false" nativetype="button" role="button" iconboth="false"></o-button-stub>
</div>
</section>
<o-modal-stub active="false" fullscreen="false" width="960" animation="zoom-out" cancelable="escape,x,outside,button" scroll="keep" trapfocus="true" ariarole="dialog" aria-label="Edit user email" destroyonhide="false" autofocus="true" closeicon="close" closeiconsize="medium" teleport="false" events="[object Object]" container="body" close-button-aria-label="Close" aria-modal=""></o-modal-stub>
<o-modal-stub active="false" fullscreen="false" width="960" animation="zoom-out" cancelable="escape,x,outside,button" scroll="keep" trapfocus="true" ariarole="dialog" aria-label="Edit user email" destroyonhide="false" autofocus="true" closeicon="close" closeiconsize="medium" teleport="false" events="[object Object]" container="body" has-modal-card="" close-button-aria-label="Close" aria-modal=""></o-modal-stub>
<o-modal-stub active="false" fullscreen="false" width="960" animation="zoom-out" cancelable="escape,x,outside,button" scroll="keep" trapfocus="true" ariarole="dialog" aria-label="Edit user email" destroyonhide="false" autofocus="true" closeicon="close" closeiconsize="medium" teleport="false" events="[object Object]" container="body" has-modal-card="" close-button-aria-label="Close" aria-modal=""></o-modal-stub>
</div>"
`;

View File

@@ -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<string, RequestHandler>;
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();
});
});

View File

@@ -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<string, RequestHandler>;
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();
});
});