diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 621aabef..bee90181 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,7 +109,7 @@ In `dev` mode, Cannery will listen for these environment variables at runtime. Defaults to `false`. - `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. -- `LOCALE`: Sets a custom locale. Defaults to `en_US`. +- `LOCALE`: Sets a custom default locale. Defaults to `en_US`. - Available options: `en_US`, `de`, and `fr` ## `MIX_ENV=test` diff --git a/README.md b/README.md index 76d597d4..6102d3a3 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ You can use the following environment variables to configure Cannery in with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. -- `LOCALE`: Sets a custom locale. Defaults to `en_US` +- `LOCALE`: Sets a custom default locale. Defaults to `en_US` - Available options: `en_US`, `de`, and `fr` - `SMTP_HOST`: The url for your SMTP email provider. Must be set - `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. diff --git a/lib/cannery/accounts.ex b/lib/cannery/accounts.ex index 55c2c6bc..c1d038af 100644 --- a/lib/cannery/accounts.ex +++ b/lib/cannery/accounts.ex @@ -269,6 +269,35 @@ defmodule Cannery.Accounts do end end + @doc """ + Returns an `%Changeset{}` for changing the user locale. + + ## Examples + + iex> change_user_locale(user) + %Changeset{data: %User{}} + + """ + @spec change_user_locale(User.t()) :: Changeset.t(User.t()) + def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale) + + @doc """ + Updates the user locale. + + ## Examples + + iex> update_user_locale(user, "valid locale") + {:ok, %User{}} + + iex> update_user_password(user, "invalid locale") + {:error, %Changeset{}} + + """ + @spec update_user_locale(User.t(), locale :: String.t()) :: + {:ok, User.t()} | {:error, Changeset.t(User.t())} + def update_user_locale(user, locale), + do: user |> User.locale_changeset(locale) |> Repo.update() + @doc """ Deletes a user. must be performed by an admin or the same user! diff --git a/lib/cannery/accounts/user.ex b/lib/cannery/accounts/user.ex index 1ac4e966..88ff9cf5 100644 --- a/lib/cannery/accounts/user.ex +++ b/lib/cannery/accounts/user.ex @@ -18,6 +18,7 @@ defmodule Cannery.Accounts.User do field :hashed_password, :string field :confirmed_at, :naive_datetime field :role, Ecto.Enum, values: [:admin, :user], default: :user + field :locale, :string has_many :invites, Invite, on_delete: :delete_all @@ -31,6 +32,7 @@ defmodule Cannery.Accounts.User do hashed_password: String.t(), confirmed_at: NaiveDateTime.t(), role: atom(), + locale: String.t() | nil, invites: [Invite.t()], inserted_at: NaiveDateTime.t(), updated_at: NaiveDateTime.t() @@ -60,7 +62,7 @@ defmodule Cannery.Accounts.User do Changeset.t(t() | new_user()) def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :password, :role]) + |> cast(attrs, [:email, :password, :role, :locale]) |> validate_email() |> validate_password(opts) end @@ -185,4 +187,14 @@ defmodule Cannery.Accounts.User do do: changeset, else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) end + + @doc """ + A changeset for changing the user's locale + """ + @spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t()) + def locale_changeset(user_or_changeset, locale) do + user_or_changeset + |> cast(%{"locale" => locale}, [:locale]) + |> validate_required(:locale) + end end diff --git a/lib/cannery_web.ex b/lib/cannery_web.ex index bdf581d3..1fbd9a43 100644 --- a/lib/cannery_web.ex +++ b/lib/cannery_web.ex @@ -47,6 +47,7 @@ defmodule CanneryWeb do use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, "live.html"} + on_mount CanneryWeb.InitAssigns unquote(view_helpers()) end end diff --git a/lib/cannery_web/controllers/user_settings_controller.ex b/lib/cannery_web/controllers/user_settings_controller.ex index 312a03bb..10e69f4b 100644 --- a/lib/cannery_web/controllers/user_settings_controller.ex +++ b/lib/cannery_web/controllers/user_settings_controller.ex @@ -10,10 +10,11 @@ defmodule CanneryWeb.UserSettingsController do render(conn, "edit.html", page_title: gettext("Settings")) end - def update(conn, %{"action" => "update_email"} = params) do - %{"current_password" => password, "user" => user_params} = params - user = conn.assigns.current_user - + def update(%{assigns: %{current_user: user}} = conn, %{ + "action" => "update_email", + "current_password" => password, + "user" => user_params + }) do case Accounts.apply_user_email(user, password, user_params) do {:ok, applied_user} -> Accounts.deliver_update_email_instructions( @@ -33,14 +34,15 @@ defmodule CanneryWeb.UserSettingsController do |> redirect(to: Routes.user_settings_path(conn, :edit)) {:error, changeset} -> - render(conn, "edit.html", email_changeset: changeset) + conn |> render("edit.html", email_changeset: changeset) end end - def update(conn, %{"action" => "update_password"} = params) do - %{"current_password" => password, "user" => user_params} = params - user = conn.assigns.current_user - + def update(%{assigns: %{current_user: user}} = conn, %{ + "action" => "update_password", + "current_password" => password, + "user" => user_params + }) do case Accounts.update_user_password(user, password, user_params) do {:ok, user} -> conn @@ -49,12 +51,27 @@ defmodule CanneryWeb.UserSettingsController do |> UserAuth.log_in_user(user) {:error, changeset} -> - render(conn, "edit.html", password_changeset: changeset) + conn |> render("edit.html", password_changeset: changeset) end end - def confirm_email(conn, %{"token" => token}) do - case Accounts.update_user_email(conn.assigns.current_user, token) do + def update( + %{assigns: %{current_user: user}} = conn, + %{"action" => "update_locale", "user" => %{"locale" => locale}} + ) do + case Accounts.update_user_locale(user, locale) do + {:ok, _user} -> + conn + |> put_flash(:info, dgettext("prompts", "Language updated successfully.")) + |> redirect(to: Routes.user_settings_path(conn, :edit)) + + {:error, changeset} -> + conn |> render("edit.html", locale_changeset: changeset) + end + end + + def confirm_email(%{assigns: %{current_user: user}} = conn, %{"token" => token}) do + case Accounts.update_user_email(user, token) do :ok -> conn |> put_flash(:info, dgettext("prompts", "Email changed successfully.")) @@ -84,11 +101,10 @@ defmodule CanneryWeb.UserSettingsController do end end - defp assign_email_and_password_changesets(conn, _opts) do - user = conn.assigns.current_user - + defp assign_email_and_password_changesets(%{assigns: %{current_user: user}} = conn, _opts) do conn |> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(:password_changeset, Accounts.change_user_password(user)) + |> assign(:locale_changeset, Accounts.change_user_locale(user)) end end diff --git a/lib/cannery_web/live/ammo_group_live/index.ex b/lib/cannery_web/live/ammo_group_live/index.ex index 3b5e3481..e5fe96c3 100644 --- a/lib/cannery_web/live/ammo_group_live/index.ex +++ b/lib/cannery_web/live/ammo_group_live/index.ex @@ -8,8 +8,8 @@ defmodule CanneryWeb.AmmoGroupLive.Index do alias CanneryWeb.Endpoint @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session) |> display_ammo_groups()} + def mount(_params, _session, socket) do + {:ok, socket |> display_ammo_groups()} end @impl true diff --git a/lib/cannery_web/live/ammo_group_live/show.ex b/lib/cannery_web/live/ammo_group_live/show.ex index c019ce35..58f2e1ed 100644 --- a/lib/cannery_web/live/ammo_group_live/show.ex +++ b/lib/cannery_web/live/ammo_group_live/show.ex @@ -10,9 +10,7 @@ defmodule CanneryWeb.AmmoGroupLive.Show do alias Phoenix.LiveView.Socket @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session)} - end + def mount(_params, _session, socket), do: {:ok, socket} @impl true def handle_params( diff --git a/lib/cannery_web/live/ammo_type_live/index.ex b/lib/cannery_web/live/ammo_type_live/index.ex index 2fbe1e86..25e51420 100644 --- a/lib/cannery_web/live/ammo_type_live/index.ex +++ b/lib/cannery_web/live/ammo_type_live/index.ex @@ -9,8 +9,8 @@ defmodule CanneryWeb.AmmoTypeLive.Index do alias CanneryWeb.Endpoint @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session) |> list_ammo_types()} + def mount(_params, _session, socket) do + {:ok, socket |> list_ammo_types()} end @impl true diff --git a/lib/cannery_web/live/ammo_type_live/show.ex b/lib/cannery_web/live/ammo_type_live/show.ex index c71b9169..18e74b31 100644 --- a/lib/cannery_web/live/ammo_type_live/show.ex +++ b/lib/cannery_web/live/ammo_type_live/show.ex @@ -9,9 +9,7 @@ defmodule CanneryWeb.AmmoTypeLive.Show do alias CanneryWeb.Endpoint @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session)} - end + def mount(_params, _session, socket), do: {:ok, socket} @impl true def handle_params(%{"id" => id}, _params, %{assigns: %{current_user: current_user}} = socket) do diff --git a/lib/cannery_web/live/container_live/index.ex b/lib/cannery_web/live/container_live/index.ex index eb505b79..28d646cb 100644 --- a/lib/cannery_web/live/container_live/index.ex +++ b/lib/cannery_web/live/container_live/index.ex @@ -10,9 +10,7 @@ defmodule CanneryWeb.ContainerLive.Index do alias Ecto.Changeset @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session)} - end + def mount(_params, _session, socket), do: {:ok, socket} @impl true def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do diff --git a/lib/cannery_web/live/container_live/show.ex b/lib/cannery_web/live/container_live/show.ex index 8bf74414..26665015 100644 --- a/lib/cannery_web/live/container_live/show.ex +++ b/lib/cannery_web/live/container_live/show.ex @@ -11,9 +11,7 @@ defmodule CanneryWeb.ContainerLive.Show do alias Phoenix.LiveView.Socket @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session)} - end + def mount(_params, _session, socket), do: {:ok, socket} @impl true def handle_params( diff --git a/lib/cannery_web/live/home_live.ex b/lib/cannery_web/live/home_live.ex index 7d901177..c8ebe34a 100644 --- a/lib/cannery_web/live/home_live.ex +++ b/lib/cannery_web/live/home_live.ex @@ -7,14 +7,9 @@ defmodule CanneryWeb.HomeLive do alias Cannery.Accounts @impl true - def mount(_params, session, socket) do + def mount(_params, _session, socket) do admins = Accounts.list_users_by_role(:admin) - - socket = - socket - |> assign_defaults(session) - |> assign(page_title: "Home", query: "", results: %{}, admins: admins) - + socket = socket |> assign(page_title: "Home", query: "", results: %{}, admins: admins) {:ok, socket} end diff --git a/lib/cannery_web/live/init_assigns.ex b/lib/cannery_web/live/init_assigns.ex new file mode 100644 index 00000000..60f75dce --- /dev/null +++ b/lib/cannery_web/live/init_assigns.ex @@ -0,0 +1,19 @@ +defmodule CanneryWeb.InitAssigns do + @moduledoc """ + Ensures common `assigns` are applied to all LiveViews attaching this hook. + """ + import Phoenix.LiveView + alias Cannery.Accounts + + def on_mount(:default, _params, %{"locale" => locale, "user_token" => user_token}, socket) do + Gettext.put_locale(locale) + + socket = + socket + |> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end) + + {:cont, socket} + end + + def on_mount(:default, _params, _session, socket), do: {:cont, socket} +end diff --git a/lib/cannery_web/live/invite_live/index.ex b/lib/cannery_web/live/invite_live/index.ex index 8715e0f0..2a75059b 100644 --- a/lib/cannery_web/live/invite_live/index.ex +++ b/lib/cannery_web/live/invite_live/index.ex @@ -10,9 +10,7 @@ defmodule CanneryWeb.InviteLive.Index do alias Phoenix.LiveView.JS @impl true - def mount(_params, session, socket) do - %{assigns: %{current_user: current_user}} = socket = socket |> assign_defaults(session) - + def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do socket = if current_user |> Map.get(:role) == :admin do socket |> display_invites() diff --git a/lib/cannery_web/live/live_helpers.ex b/lib/cannery_web/live/live_helpers.ex index 7e278a2b..d6b9d7ed 100644 --- a/lib/cannery_web/live/live_helpers.ex +++ b/lib/cannery_web/live/live_helpers.ex @@ -3,20 +3,9 @@ defmodule CanneryWeb.LiveHelpers do Contains common helper functions for liveviews """ - import Phoenix.LiveView import Phoenix.LiveView.Helpers - alias Cannery.Accounts alias Phoenix.LiveView.JS - def assign_defaults(socket, %{"user_token" => user_token} = _session) do - socket - |> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end) - end - - def assign_defaults(socket, _session) do - socket - end - @doc """ Renders a live component inside a modal. diff --git a/lib/cannery_web/live/range_live/index.ex b/lib/cannery_web/live/range_live/index.ex index 8951dfcc..ff711b7a 100644 --- a/lib/cannery_web/live/range_live/index.ex +++ b/lib/cannery_web/live/range_live/index.ex @@ -10,9 +10,7 @@ defmodule CanneryWeb.RangeLive.Index do alias Phoenix.LiveView.Socket @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session) |> display_shot_groups()} - end + def mount(_params, _session, socket), do: {:ok, socket |> display_shot_groups()} @impl true def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do diff --git a/lib/cannery_web/live/tag_live/index.ex b/lib/cannery_web/live/tag_live/index.ex index 15e9012f..fbd1deed 100644 --- a/lib/cannery_web/live/tag_live/index.ex +++ b/lib/cannery_web/live/tag_live/index.ex @@ -9,9 +9,7 @@ defmodule CanneryWeb.TagLive.Index do alias CanneryWeb.Endpoint @impl true - def mount(_params, session, socket) do - {:ok, socket |> assign_defaults(session) |> display_tags()} - end + def mount(_params, _session, socket), do: {:ok, socket |> display_tags()} @impl true def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do diff --git a/lib/cannery_web/router.ex b/lib/cannery_web/router.ex index 88acf06e..f7f77c75 100644 --- a/lib/cannery_web/router.ex +++ b/lib/cannery_web/router.ex @@ -11,8 +11,17 @@ defmodule CanneryWeb.Router do plug :protect_from_forgery plug :put_secure_browser_headers plug :fetch_current_user + plug :put_user_locale, default: Application.get_env(:gettext, :default_locale, "en_US") + end - Gettext.put_locale(Application.get_env(:gettext, :default_locale, "en_US")) + defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, default: default) do + Gettext.put_locale(locale || default) + conn |> put_session(:locale, locale || default) + end + + defp put_user_locale(conn, default: default) do + Gettext.put_locale(default) + conn |> put_session(:locale, default) end pipeline :require_admin do diff --git a/lib/cannery_web/templates/user_registration/new.html.heex b/lib/cannery_web/templates/user_registration/new.html.heex index ddc9cb81..5ff5a2cc 100644 --- a/lib/cannery_web/templates/user_registration/new.html.heex +++ b/lib/cannery_web/templates/user_registration/new.html.heex @@ -30,6 +30,15 @@ <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> <%= error_tag(f, :password, "col-span-3") %> + <%= label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600") %> + <%= select( + f, + :locale, + [{gettext("English"), "en_US"}, {gettext("German"), "de"}, {gettext("French"), "fr"}], + class: "input input-primary col-span-2" + ) %> + <%= error_tag(f, :locale) %> + <%= submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3") %> <% end %> diff --git a/lib/cannery_web/templates/user_settings/edit.html.heex b/lib/cannery_web/templates/user_settings/edit.html.heex index c0f500db..56a8116e 100644 --- a/lib/cannery_web/templates/user_settings/edit.html.heex +++ b/lib/cannery_web/templates/user_settings/edit.html.heex @@ -1,17 +1,16 @@ -
+ <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> +
+