diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index 80290bafb..37bc79fba 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -179,6 +179,26 @@ defmodule Admin.Accounts do |> update_user_and_delete_all_tokens() end + def change_user_name(user, attrs \\ %{}) do + User.name_changeset(user, attrs) + end + + def update_user_name(user, attrs \\ %{}) do + user + |> User.name_changeset(attrs) + |> Repo.update() + end + + def change_user_language(user, attrs \\ %{}) do + User.language_changeset(user, attrs) + end + + def update_user_language(user, attrs \\ %{}) do + user + |> User.language_changeset(attrs) + |> Repo.update() + end + ## Session @doc """ diff --git a/lib/admin/accounts/user.ex b/lib/admin/accounts/user.ex index 58cb88531..23e42d63d 100644 --- a/lib/admin/accounts/user.ex +++ b/lib/admin/accounts/user.ex @@ -10,6 +10,8 @@ defmodule Admin.Accounts.User do # when we unify the access control for all users and use user roles to define who is an admin or not. schema "admins" do field :email, :string + field :name, :string + field :language, :string field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true field :confirmed_at, :utc_datetime @@ -138,4 +140,17 @@ defmodule Admin.Accounts.User do Bcrypt.no_user_verify() false end + + def name_changeset(user, attrs) do + user + |> cast(attrs, [:name]) + |> validate_required([:name]) + end + + def language_changeset(user, attrs) do + user + |> cast(attrs, [:language]) + |> validate_required([:language]) + |> validate_inclusion(:language, Admin.Languages.all_values()) + end end diff --git a/lib/admin/languages.ex b/lib/admin/languages.ex new file mode 100644 index 000000000..a8698559c --- /dev/null +++ b/lib/admin/languages.ex @@ -0,0 +1,57 @@ +defmodule Admin.Languages do + @moduledoc """ + This module handles the currently supported languages. + + It allows to get a language list suitable for displaying a select input with some options disabled. + """ + @languages [ + %{value: "en", key: "English"}, + %{value: "fr", key: "French"}, + %{value: "es", key: "Spanish"}, + %{value: "de", key: "German"}, + %{value: "it", key: "Italian"} + ] + + def all do + @languages + end + + def all_options do + @languages |> Enum.map(&Keyword.new(&1)) + end + + def all_values do + @languages |> Enum.map(& &1.value) + end + + @doc """ + Returns a list of languages excluding the ones with the given codes. + + ## Examples + iex> Admin.Languages.excluding(["en", "fr"]) + [%{value: "es", key: "Spanish"}, %{value: "de", key: "German"}, %{value: "it", key: "Italian"}] + """ + def excluding(language_codes) when is_list(language_codes) do + @languages |> Enum.reject(&(&1.value in language_codes)) + end + + @doc """ + Returns a list of keyword lists with languages with the disabled languages. Can be used in select options. + + ## Examples + iex> Admin.Languages.disabling(["en", "fr"]) + [ + [value: "en", key: "English", disabled: true], + [value: "fr", key: "French", disabled: true], + [value: "es", key: "Spanish", disabled: false], + [value: "de", key: "German", disabled: false], + [value: "it", key: "Italian", disabled: false] + ] + """ + def disabling(language_codes) when is_list(language_codes) do + @languages + |> Enum.map(fn %{value: value, key: key} -> + Keyword.new(value: value, key: key, disabled: value in language_codes) + end) + end +end diff --git a/lib/admin_web/controllers/admin_html/dashboard.html.heex b/lib/admin_web/controllers/admin_html/dashboard.html.heex index a12fe74db..81068e524 100644 --- a/lib/admin_web/controllers/admin_html/dashboard.html.heex +++ b/lib/admin_web/controllers/admin_html/dashboard.html.heex @@ -1,6 +1,6 @@ <.header> - Welcome, {@current_scope.user.email} + Welcome, {@current_scope.user.name || @current_scope.user.email}
diff --git a/lib/admin_web/controllers/user_html/show.html.heex b/lib/admin_web/controllers/user_html/show.html.heex index 6cbe7ecbb..71d79e418 100644 --- a/lib/admin_web/controllers/user_html/show.html.heex +++ b/lib/admin_web/controllers/user_html/show.html.heex @@ -7,30 +7,10 @@ <.list> <:item title="ID">{@user.id} - <:item title="Name">{@user.email} + <:item title="Name">{@user.name} + <:item title="Email">{@user.email} + <:item title="Language">{@user.language} <:item title="Inserted at">{@user.created_at} <:item title="Confirmed at">{@user.confirmed_at || "Not confirmed yet"} - - <%!-- <.header> - Removal Notices - <:subtitle>Records of publications that have been removed for this user. - - <%= if Enum.empty?(@user.removal_notices) do %> -

No removal notices for user yet

- <% else %> - <.table id="removal-notices" rows={@user.removal_notices}> - <:col :let={notice} label="Name">{notice.publication_name} - <:col :let={notice} label="Removed on"> -
- <.relative_date date={notice.created_at} /> - - {notice.created_at} - -
- - <:col :let={notice} label="By">{notice.creator.email} - <:col :let={notice} label="Reason">{notice.reason} - - <% end %> --%> diff --git a/lib/admin_web/live/user_live/listing.ex b/lib/admin_web/live/user_live/listing.ex index b6de22fe6..9cda1d22a 100644 --- a/lib/admin_web/live/user_live/listing.ex +++ b/lib/admin_web/live/user_live/listing.ex @@ -24,13 +24,26 @@ defmodule AdminWeb.UserLive.Listing do class="flex flex-row justify-between" >
- <.link navigate={~p"/users/#{user}"}>{user.email} -
- You -
-
- <.icon name="hero-check-circle" class="size-4 shrink-0" /> Email + <.link navigate={~p"/users/#{user}"}> + {user.name} + +
+ {user.email} +
+ You +
+
+ <.icon name="hero-check-circle" class="size-4 shrink-0" /> Email +
+
+ +
+ Language: {user.language}
+
{user.id} diff --git a/lib/admin_web/live/user_live/settings.ex b/lib/admin_web/live/user_live/settings.ex index cbe6f09b6..b36ee5077 100644 --- a/lib/admin_web/live/user_live/settings.ex +++ b/lib/admin_web/live/user_live/settings.ex @@ -16,6 +16,27 @@ defmodule AdminWeb.UserLive.Settings do
+ <.form + for={@name_form} + id="name_form" + phx-change="validate_name" + phx-submit="update_name" + > + <.input + field={@name_form[:name]} + type="text" + label="Name" + autocomplete="name" + required + /> + + <.button variant="primary" phx-disable-with="Saving..."> + Save Name + + + +
+ <.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email"> <.input field={@email_form[:email]} @@ -29,6 +50,27 @@ defmodule AdminWeb.UserLive.Settings do
+ <.form + for={@language_form} + id="language_form" + phx-change="validate_language" + phx-submit="update_language" + > + <.input + field={@language_form[:language]} + type="select" + label="Language" + options={Admin.Languages.all_options()} + required + /> + + <.button variant="primary" phx-disable-with="Saving..."> + Save Language + + + +
+ <.form for={@password_form} id="password_form" @@ -84,12 +126,17 @@ defmodule AdminWeb.UserLive.Settings do user = socket.assigns.current_scope.user email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false) password_changeset = Accounts.change_user_password(user, %{}, hash_password: false) + name_changeset = Accounts.change_user_name(user, %{}) + language_changeset = Accounts.change_user_language(user, %{}) socket = socket + |> assign(:page_title, "Account Settings") |> assign(:current_email, user.email) |> assign(:email_form, to_form(email_changeset)) |> assign(:password_form, to_form(password_changeset)) + |> assign(:name_form, to_form(name_changeset)) + |> assign(:language_form, to_form(language_changeset)) |> assign(:trigger_submit, false) {:ok, socket} @@ -154,4 +201,64 @@ defmodule AdminWeb.UserLive.Settings do {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))} end end + + def handle_event("validate_name", params, socket) do + %{"user" => user_params} = params + + name_form = + socket.assigns.current_scope.user + |> Accounts.change_user_name(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, name_form: name_form)} + end + + def handle_event("update_name", params, socket) do + %{"user" => user_params} = params + user = socket.assigns.current_scope.user + true = Accounts.sudo_mode?(user) + + case Accounts.update_user_name( + user, + user_params + ) do + {:ok, _user} -> + info = "The user name has been updated." + {:noreply, socket |> put_flash(:info, info)} + + %Ecto.Changeset{} = changeset -> + {:noreply, assign(socket, name_form: to_form(changeset, action: :insert))} + end + end + + def handle_event("validate_language", params, socket) do + %{"user" => user_params} = params + + language_form = + socket.assigns.current_scope.user + |> Accounts.change_user_language(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, language_form: language_form)} + end + + def handle_event("update_language", params, socket) do + %{"user" => user_params} = params + user = socket.assigns.current_scope.user + true = Accounts.sudo_mode?(user) + + case Accounts.update_user_language( + user, + user_params + ) do + {:ok, _user} -> + info = "The user language has been updated." + {:noreply, socket |> put_flash(:info, info)} + + %Ecto.Changeset{} = changeset -> + {:noreply, assign(socket, name_form: to_form(changeset, action: :insert))} + end + end end diff --git a/priv/repo/migrations/20251217123725_add_user_name_language.exs b/priv/repo/migrations/20251217123725_add_user_name_language.exs new file mode 100644 index 000000000..0fb173545 --- /dev/null +++ b/priv/repo/migrations/20251217123725_add_user_name_language.exs @@ -0,0 +1,10 @@ +defmodule Admin.Repo.Migrations.AddUserNameLanguage do + use Ecto.Migration + + def change do + alter table(:admins) do + add :name, :string + add :language, :string, default: "en" + end + end +end diff --git a/test/admin/accounts_test.exs b/test/admin/accounts_test.exs index 4f8324ae4..727bedcd1 100644 --- a/test/admin/accounts_test.exs +++ b/test/admin/accounts_test.exs @@ -252,6 +252,116 @@ defmodule Admin.AccountsTest do end end + describe "change_user_name/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_name(%User{}) + assert changeset.required == [:name] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_name( + %User{}, + %{ + "name" => "new valid name" + } + ) + + assert changeset.valid? + assert get_change(changeset, :name) == "new valid name" + end + end + + describe "update_user_name/2" do + setup do + %{user: user_fixture()} + end + + test "validates name", %{user: user} do + {:error, changeset} = + Accounts.update_user_name(user, %{ + name: nil + }) + + assert %{ + name: ["can't be blank"] + } = errors_on(changeset) + end + + test "updates the name", %{user: user} do + {:ok, user} = + Accounts.update_user_name(user, %{ + name: "new valid name" + }) + + assert user == Accounts.get_user_by_email(user.email) + end + end + + describe "change_user_language/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_language(%User{}) + assert changeset.required == [:language] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_language( + %User{}, + %{ + "language" => "fr" + } + ) + + assert changeset.valid? + assert get_change(changeset, :language) == "fr" + end + + test "invalid language returns changeset error" do + changeset = + Accounts.change_user_language( + %User{}, + %{ + "language" => "invalid" + } + ) + + refute changeset.valid? + + assert changeset.errors == [ + language: + {"is invalid", + [{:validation, :inclusion}, {:enum, ["en", "fr", "es", "de", "it"]}]} + ] + end + end + + describe "update_user_language/2" do + setup do + %{user: user_fixture()} + end + + test "validates language", %{user: user} do + {:error, changeset} = + Accounts.update_user_language(user, %{ + language: nil + }) + + assert %{ + language: ["can't be blank"] + } = errors_on(changeset) + end + + test "updates the language", %{user: user} do + {:ok, user} = + Accounts.update_user_language(user, %{ + language: "fr" + }) + + assert user == Accounts.get_user_by_email(user.email) + end + end + describe "generate_user_session_token/1" do setup do %{user: user_fixture()}