diff --git a/js/src/common.scss b/js/src/common.scss index 65301b01b..2d882fb28 100644 --- a/js/src/common.scss +++ b/js/src/common.scss @@ -7,10 +7,6 @@ @import "styles/vue-announcer.scss"; @import "styles/vue-skip-to.scss"; -// a { -// color: $violet-2; -// } - a.out, .content a, .ProseMirror a { @@ -19,18 +15,10 @@ a.out, text-decoration-thickness: 2px; } -// input.input { -// border-color: $input-border-color !important; -// } - .section { padding: 1rem 1% 4rem; } -figure img.is-rounded { - border: 1px solid #cdcaea; -} - $color-black: #000; .mention { diff --git a/js/src/components/Account/ActorCard.vue b/js/src/components/Account/ActorCard.vue index d319bd898..daead16b4 100644 --- a/js/src/components/Account/ActorCard.vue +++ b/js/src/components/Account/ActorCard.vue @@ -1,22 +1,38 @@ - - - diff --git a/js/src/components/Event/EventMetadataSidebar.vue b/js/src/components/Event/EventMetadataSidebar.vue index 19be9ab3a..7964d99d0 100644 --- a/js/src/components/Event/EventMetadataSidebar.vue +++ b/js/src/components/Event/EventMetadataSidebar.vue @@ -34,12 +34,6 @@ class="metadata-organized-by" :title="$t('Organized by')" > - - - - - - + :actor="event.attributedTo" + /> + - - - - + /> -
- -
- - - - - - - - -
{{ key }} - - {{ value }} - - {{ value }}
-
- {{ $t("Suspend") }} +
+

{{ $t("Details") }}

+
+
+
+
+ + + + + + + + +
+ {{ key }} + + + {{ value }} + + + {{ value }} +
+
+
+
+
+
+
+

{{ $t("Actions") }}

+
+ {{ $t("Suspend") }} + {{ $t("Unsuspend") }} +
+

+ -
-

- {{ - $tc("{number} organized events", person.organizedEvents.total, { - number: person.organizedEvents.total, - }) - }} -

+ + + +
+ +
+

{{ $t("Organized events") }}

-
-

- {{ - $tc("{number} participations", person.participations.total, { - number: person.participations.total, - }) - }} -

+
+

{{ $t("Participations") }}

- {{ - $tc("{number} memberships", person.memberships.total, { - number: person.memberships.total, - }) - }} - +
+

{{ $t("Memberships") }}

- - diff --git a/js/src/views/Admin/AdminUserProfile.vue b/js/src/views/Admin/AdminUserProfile.vue index 6905783f3..ed11e5cb4 100644 --- a/js/src/views/Admin/AdminUserProfile.vue +++ b/js/src/views/Admin/AdminUserProfile.vue @@ -15,48 +15,113 @@ ]" /> - - - - - - - - - - - -
{{ key }} -
    -
  • - - {{ - $t("{profile} (by default)", { profile: value }) - }} - {{ value }} - -
  • -
-
- {{ $t("None") }} - - - {{ value }} - - - {{ value }} - {{ value }}
-
- {{ $t("Suspend") }} -
+
+

{{ $t("Details") }}

+
+
+
+
+ + + + + + + + + + + + +
+ {{ key }} + +
    +
  • + + {{ + $t("{profile} (by default)", { profile: value }) + }} + {{ value }} + +
  • +
+
+ {{ $t("None") }} + + + {{ value }} + + + {{ value }} + + + {{ value }} + + + {{ value }} +
+
+
+
+
+
+
+

{{ $t("Actions") }}

+
+ {{ $t("Suspend") }} +
+
+
+

{{ $t("Profiles") }}

+
+ + + +
+
{{ $t("This user was not found") }} @@ -76,11 +141,12 @@ import { Route } from "vue-router"; import { formatBytes } from "@/utils/datetime"; import { ICurrentUserRole } from "@/types/enums"; import { GET_USER, SUSPEND_USER } from "../../graphql/user"; -import { usernameWithDomain } from "../../types/actor/actor.model"; +import { IActor, usernameWithDomain } from "../../types/actor/actor.model"; import RouteName from "../../router/name"; import { IUser } from "../../types/current-user.model"; -import { IPerson } from "../../types/actor"; import EmptyContent from "../../components/Utils/EmptyContent.vue"; +import ActorCard from "../../components/Account/ActorCard.vue"; +import { LANGUAGES_CODES } from "@/graphql/admin"; @Component({ apollo: { @@ -96,6 +162,17 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue"; return !this.id; }, }, + languages: { + query: LANGUAGES_CODES, + variables() { + return { + codes: [this.languageCode], + }; + }, + skip() { + return !this.languageCode; + }, + }, }, metaInfo() { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -107,6 +184,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue"; }, components: { EmptyContent, + ActorCard, }, }) export default class AdminUserProfile extends Vue { @@ -114,6 +192,8 @@ export default class AdminUserProfile extends Vue { user!: IUser; + languages!: Array<{ code: string; name: string }>; + usernameWithDomain = usernameWithDomain; RouteName = RouteName; @@ -127,11 +207,14 @@ export default class AdminUserProfile extends Vue { }, { key: this.$i18n.t("Language"), - value: this.user.locale, + value: this.languages + ? this.languages[0].name + : this.$i18n.t("Unknown"), }, { key: this.$i18n.t("Role"), value: this.roleName(this.user.role), + type: "badge", }, { key: this.$i18n.t("Login status"), @@ -139,20 +222,6 @@ export default class AdminUserProfile extends Vue { ? this.$i18n.t("Disabled") : this.$t("Activated"), }, - { - key: this.$i18n.t("Profiles"), - elements: this.user.actors.map((actor: IPerson) => { - return { - link: { name: RouteName.ADMIN_PROFILE, params: { id: actor.id } }, - value: actor.name - ? `${actor.name} (${actor.preferredUsername})` - : actor.preferredUsername, - active: this.user.defaultActor - ? actor.id === this.user.defaultActor.id - : false, - }; - }), - }, { key: this.$i18n.t("Confirmed"), value: @@ -175,12 +244,16 @@ export default class AdminUserProfile extends Vue { type: "code", }, { - key: this.$i18n.t("Participations"), + key: this.$i18n.t("Total number of participations"), value: this.user.participations.total, }, { - key: this.$i18n.t("Uploaded media size"), - value: formatBytes(this.user.mediaSize), + key: this.$i18n.t("Uploaded media total size"), + value: formatBytes( + this.user.mediaSize, + 2, + this.$i18n.t("0 Bytes") as string + ), }, ]; } @@ -206,11 +279,13 @@ export default class AdminUserProfile extends Vue { }); return this.$router.push({ name: RouteName.USERS }); } + + get profiles(): IActor[] { + return this.user.actors; + } + + get languageCode(): string | undefined { + return this.user?.locale; + } } - - diff --git a/js/src/views/Admin/Users.vue b/js/src/views/Admin/Users.vue index c018f3366..3f52b2563 100644 --- a/js/src/views/Admin/Users.vue +++ b/js/src/views/Admin/Users.vue @@ -16,7 +16,6 @@ paginated backend-pagination backend-filtering - detailed :current-page.sync="page" :aria-next-label="$t('Next page')" :aria-previous-label="$t('Previous page')" @@ -75,31 +74,6 @@ > {{ getLanguageNameForCode(props.row.locale) }} - -
diff --git a/js/tailwind.config.js b/js/tailwind.config.js index fa03e280b..789383b10 100644 --- a/js/tailwind.config.js +++ b/js/tailwind.config.js @@ -14,8 +14,9 @@ module.exports = { colors: { primary: withOpacityValue("--color-primary"), secondary: withOpacityValue("--color-secondary"), + "violet-title": withOpacityValue("--color-violet-title"), }, }, }, - plugins: [], + plugins: [require("@tailwindcss/line-clamp")], }; diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex index 54c407ec4..6fc6b3bb6 100644 --- a/lib/graphql/resolvers/admin.ex +++ b/lib/graphql/resolvers/admin.ex @@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do import Mobilizon.Users.Guards - alias Mobilizon.{Actors, Admin, Config, Events, Instances} + alias Mobilizon.{Actors, Admin, Config, Events, Instances, Users} alias Mobilizon.Actors.{Actor, Follower} alias Mobilizon.Admin.{ActionLog, Setting} alias Mobilizon.Cldr.Language @@ -281,6 +281,86 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do dgettext("errors", "You need to be logged-in and an administrator to save admin settings")} end + def update_user(_parent, %{id: id, notify: notify} = args, %{ + context: %{current_user: %User{role: role}} + }) + when is_admin(role) do + case Users.get_user(id) do + nil -> + {:error, :user_not_found} + + %User{} = user -> + case args |> Map.drop([:notify, :id]) |> Map.keys() do + [] -> + {:error, :invalid_argument} + + [change, _] -> + case change do + :email -> change_email(user, Map.get(args, :email), notify) + :role -> change_role(user, Map.get(args, :role), notify) + :confirmed -> confirm_user(user, Map.get(args, :confirmed), notify) + end + end + end + end + + def update_user(_parent, _args, _resolution) do + {:error, + dgettext("errors", "You need to be logged-in and an administrator to edit an user's details")} + end + + @spec change_email(User.t(), String.t(), boolean()) + defp change_email(%User{email: old_email} = user, new_email, notify) do + if Authenticator.can_change_email?(user) do + if new_email != old_email do + if Email.Checker.valid?(new_email) do + case Users.update_user_email(user, new_email) do + {:ok, %User{} = user} -> + user + |> Email.User.send_email_reset_old_email() + |> Email.Mailer.send_email_later() + + user + |> Email.User.send_email_reset_new_email() + |> Email.Mailer.send_email_later() + + {:ok, user} + + {:error, %Ecto.Changeset{} = err} -> + Logger.debug(inspect(err)) + {:error, dgettext("errors", "Failed to update user email")} + end + else + {:error, dgettext("errors", "The new email doesn't seem to be valid")} + end + else + {:error, dgettext("errors", "The new email must be different")} + end + end + end + + defp change_role(%User{role: old_role} = user, new_role, notify) do + if old_role != new_role do + Users.update_user(user, %{role: new_role}) + end + end + + defp confirm_user(%User{confirmed_at: old_confirmed_at} = user, confirmed, notify) do + new_confirmed_at = + cond do + is_nil(old_confirmed_at) && confirmed -> + DateTime.utc_now() + + match?(%DateTime{}, old_confirmed_at) && !confirmed -> + nil + + true -> + old_confirmed_at + end + + Users.update_user(user, %{confirmed_at: new_confirmed_at}) + end + @spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) :: {:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated} def list_relay_followers( diff --git a/lib/graphql/schema/admin.ex b/lib/graphql/schema/admin.ex index 44b70274d..712c53762 100644 --- a/lib/graphql/schema/admin.ex +++ b/lib/graphql/schema/admin.ex @@ -409,5 +409,22 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do resolve(&Admin.save_settings/3) end + + @desc """ + For an admin to update an user + """ + field :admin_update_user, type: :user do + arg(:id, non_null(:id), description: "The user's ID") + arg(:email, :string, description: "The user's new email") + arg(:confirmed, :string, description: "Manually confirm the user's account") + arg(:role, :user_role, description: "Set user's new role") + + arg(:notify, :boolean, + default_value: false, + description: "Whether or not to notify the user of the change" + ) + + resolve(&Admin.update_user/3) + end end end