Pow: How to use pow for multilingual site

Created on 27 Feb 2019  路  16Comments  路  Source: danschultzer/pow

I have a plug locale which redirects all requests like /registration/new to urls like /de/registration/new, /fr/registration/new, /ru/registration/new according to browser's accept-language header or by users choice.
So I need my forms to be localized and their action should lead to localized paths too because the mail should be sent also translated and also I need to save user's language for later use.

I've put pow_routes() and pow_extension_routes() into the scope in the router.ex file

scope "/:locale" do
  pow_routes()
  pow_extension_routes()
end

I've overriden all templates for registration, session, email_confirmation and reset_password and mailer view templates

mix pow.phoenix.gen.templates
mix pow.extension.phoenix.gen.templates --extension PowResetPassword --extension PowEmailConfirmation
mix pow.extension.phoenix.mailer.gen.templates --extension PowResetPassword --extension PowEmailConfirmation

And in the registration form template new.html.eex I changed the @action to "/"<>@locale<>@action to make form to be sent to localized path:

<%= form_for @changeset, "/"<>@locale<>@action, [as: :user], fn f -> %>

And made custom mailer backend according to Readme file and modified jp_web.ex file too.

And because I need all messages to be translated too I needed to create JpWeb.Pow.Messages file and add all messages from Pow and from Extensions messages files.

And of course I added lines to config file:

# Configure Pow authentication
config :jp, :pow,
  user: Jp.Users.User,
  repo: Jp.Repo,
  extensions: [PowResetPassword, PowEmailConfirmation],
  controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks,
  web_module: JpWeb,
  mailer_backend: JpWeb.PowMailer,
  web_mailer_module: JpWeb,
  messages_backend: JpWeb.Pow.Messages

And after trying to submit the form I got error:

** (exit) an exception was raised:
    ** (UndefinedFunctionError) function JpWeb.Pow.Messages.PowEmailConfirmation.Phoenix.Messages.email_confirmation_required/1 is undefined (module JpWeb.Pow.Messages.PowEmailConfirmation.Phoenix.Messages is not available)

Though the user was created and logger outputs the email into the console.

So what else do I need to do to get rid of the error?

Most helpful comment

You can make the plug much simpler since you can expect that locale is only defined in the path, and just set up fallback to default locale:

defmodule MyAppWeb.Plugs.Locale do
  import Plug.Conn

  @supported_locales Gettext.known_locales(MyAppWeb.Gettext)
  @default_locale "en"

  def init(opts), do: opts

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts)
      when locale in @supported_locales, do: set_locale(conn, locale)
  def call(conn, _opts), do: set_locale(conn, @default_locale)

  defp set_locale(conn, locale) do
     Gettext.put_locale(MyAppWeb.Gettext, locale)

    assign(conn, :locale, locale)
  end
end

But I highly recommend CLDR. That library has two plugs that can help you. As an example, you could put this in your endpoint:

plug Cldr.Plug.SetLocale,
  apps:    [:cldr, :gettext],
  from:    [:path, :cookie, :accept_language],
  param:   "locale",
  gettext: MyAppWeb.Gettext,
  cldr:    MyAppWeb.Cldr

This will first pull the locale from path, then cookie and last accept language. And then it'll set the locale for your Gettext and Cldr modules. I always use this library for any platforms where I need i18n 馃槃

All 16 comments

Oh thanks, looks like a bug! I'll work on this, and push a fix for it ASAP.

It's excellent that you are using Pow with a multilingual website. One of the businesses I was working on was going to use multilingual Pow setup but unfortunately didn't happen. So I never got around to test this properly in such setup. Excited to hear how it all works out for you!

BTW, can you just post the JpWeb.Pow.Messages module? I just need to see the headers, what use modules you have?

This is how your setup should look like:

defmodule JpWeb.Pow.Messages do
  use Pow.Phoenix.Messages
  use Pow.Extension.Phoenix.Messages,
    extensions: [ResetPassword, PowEmailConfirmation]

  import JpWeb.Gettext

  # ...
end

Also, I would also suggest that you change "/"<>@locale<>@action to generated URI instead: Routes.pow_registration_path(conn, :create, @locale).

You can look at the code is the repo:
https://github.com/igormisha/jp/invitations
Checkout to the pow_auth branch.

Oh, I see the issue, which originates from my code examples. You should change:

use Pow.Extension.Phoenix.Messages,
    extensions: [ResetPassword, EmailConfirmation]

To:

use Pow.Extension.Phoenix.Messages,
    extensions: [PowResetPassword, PowEmailConfirmation]

I鈥檒l update docs, and maybe also set up warnings when non-existing extensions are used.

Yep, the error is gone, Thanks!

Replaced
"/" <> @locale <> @action
with what you suggested with minor changes:
Routes.locale_pow_registration_path(@conn, :create, @locale)

Hi,
I'm also trying to implement multi language with a default language (when no preference).

Example urls:

/registration/new # for default
/fr/registration/new
/ru/registration/new

Since the website is already live and I don't want to change default urls with a locale. So, I have to go with a default language option however I can't find a way to manage it with pow and your recommendations above.

Here's my router.ex

  scope "/", MyAppWeb do
    pipe_through :browser
    get "/", PageController, :index
    get "/w/:query", GlossaryController, :index
    get "/games/:game", GameController, :index
    get "/contribute", ContributeController, :index
  end

  scope "/:locale" do # also tried with `_` optional version: `/:_locale`
    pipe_through :browser
    pow_routes()
    pow_extension_routes()
  end

  scope "/:locale", MyAppWeb do
    pipe_through :browser
    get "/", PageController, :index
    get "/w/:query", GlossaryController, :index
    get "/games/:game", GameController, :index
    get "/contribute", ContributeController, :index
  end

# also tried with all localized versions. 

 scope "/fr", MyAppWeb do
    pipe_through :browser
    get "/games/:game", GameController, :index
    get "/contribute", ContributeController, :index
  end

 scope "/ru", MyAppWeb do
    pipe_through :browser
    get "/games/:game", GameController, :index
    get "/contribute", ContributeController, :index
  end

My Localization Plug:

defmodule MyAppWeb.Plugs.Locale do
  import Plug.Conn

  @supported_locales Gettext.known_locales(MyAppWeb.Gettext)

  def init(_options), do: nil

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _options)
      when locale in @supported_locales do
    MyAppWeb.Gettext |> Gettext.put_locale(locale)

    conn
    |> assign(:locale, locale)
    |> put_session("locale", locale)
    |> put_resp_cookie("locale", locale, max_age: 365 * 24 * 60 * 60)
  end

  def call(conn, _options), do: conn

  defp fetch_locale_from(conn) do
    (conn.assigns[:locale] || conn.params['locale'] || conn.cookies["locale"])
    |> check_locale
  end

  defp check_locale(locale) when locale in @supported_locales, do: locale

  defp check_locale(_), do: nil
end

lib/myapp_web/pow_routes.ex # thanks to #106's dev

defmodule MyAppWeb.Pow.Routes do
  use Pow.Phoenix.Routes

  @impl true
  def path_for(conn, plug, verb, vars, query_params) do
    vars = [Map.get(conn.params, :locale, "en")] ++ vars
    url = Pow.Phoenix.Routes.url_for(conn, plug, verb, vars, query_params)
    IO.inspect(url)
    url
  end
end

and added it in config as well:

config :myapp, :pow,
  user: MyApp.Users.User,
  repo: MyApp.Repo,
  extensions: [PowResetPassword, PowEmailConfirmation],
  controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks,
  mailer_backend: MyAppWeb.PowMailer,
  web_mailer_module: MyAppWeb,
  routes_backend: MyAppWeb.Pow.Routes

I don't know where's this Routes.locale_pow_registration_path(@conn, :create, @locale) coming from but my problem is not yet there. Just in case I'm sharing registration/new.html.eex

<h1>Register</h1>

<%= form_for @changeset, Routes.pow_registration_path(@conn, :create, @conn.assigns[:locale]), [as: :user], fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, Pow.Ecto.Schema.user_id_field(@changeset) %>
  <%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset) %>
  <%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %>

  <%= label f, :password %>
  <%= password_input f, :password %>
  <%= error_tag f, :password %>

  <%= label f, :confirm_password %>
  <%= password_input f, :confirm_password %>
  <%= error_tag f, :confirm_password %>

  <div>
    <%= submit "Register" %>
  </div>
<% end %>


<span><%= link "Sign in", to: Routes.pow_session_path(@conn, :new, @conn.assigns[:locale]) %></span>


I'm having issue with call backend part. Here's my stack trace:

[info] GET /en/registration/new
[debug] Processing with Pow.Phoenix.RegistrationController.new/2
  Parameters: %{"locale" => "en"}
  Pipelines: [:browser]
"http://localhost:4000/en/registration" # it actually create a url correctly.
[info] Sent 500 in 22ms
[error] #PID<0.998.0> running MyAppWeb.Endpoint (connection #PID<0.965.0>, stream id 2) terminated
Server: localhost:4000 (http)
Request: GET /en/registration/new
** (exit) an exception was raised:
    ** (Protocol.UndefinedError) protocol Phoenix.Param not implemented for [] of type List. This protocol is implemented for the following type(s): Any, Map, Integer, BitString, Atom
        (phoenix 1.4.12) lib/phoenix/param.ex:121: Phoenix.Param.Any.to_param/1
        (MyApp 0.1.0) MyAppWeb.Router.Helpers.pow_session_path/4
        (pow 1.0.16) lib/pow/phoenix/templates/registration_template.ex:28: Pow.Phoenix.RegistrationTemplate.render/2
        (MyApp 0.1.0) lib/MyApp_web/templates/layout/app.html.eex:28: MyAppWeb.LayoutView."app.html"/1
        (phoenix 1.4.12) lib/phoenix/view.ex:410: Phoenix.View.render_to_iodata/3
        (phoenix 1.4.12) lib/phoenix/controller.ex:729: Phoenix.Controller.__put_render__/5
        (phoenix 1.4.12) lib/phoenix/controller.ex:746: Phoenix.Controller.instrument_render_and_send/4
        (pow 1.0.16) lib/pow/phoenix/controllers/registration_controller.ex:1: Pow.Phoenix.RegistrationController.action/2
        (pow 1.0.16) lib/pow/phoenix/controllers/registration_controller.ex:1: Pow.Phoenix.RegistrationController.phoenix_controller_pipeline/2
        (phoenix 1.4.12) lib/phoenix/router.ex:288: Phoenix.Router.__call__/2
        (MyApp 0.1.0) lib/MyApp_web/endpoint.ex:1: MyAppWeb.Endpoint.plug_builder_call/2
        (MyApp 0.1.0) lib/plug/debugger.ex:122: MyAppWeb.Endpoint."call (overridable 3)"/2
        (MyApp 0.1.0) lib/MyApp_web/endpoint.ex:1: MyAppWeb.Endpoint.call/2
        (phoenix 1.4.12) lib/phoenix/endpoint/cowboy2_handler.ex:42: Phoenix.Endpoint.Cowboy2Handler.init/4
        (cowboy 2.7.0) myapp/deps/cowboy/src/cowboy_handler.erl:41: :cowboy_handler.execute/2
        (cowboy 2.7.0) myapp/deps/cowboy/src/cowboy_stream_h.erl:320: :cowboy_stream_h.execute/3
        (cowboy 2.7.0) myapp/deps/cowboy/src/cowboy_stream_h.erl:302: :cowboy_stream_h.request_process/3
        (stdlib 3.11.2) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
owboy_stream_h.request_process/3
        (stdlib 3.11.2) proc_lib.erl:249: :proc_lib.init_p_do_apply/3

Thank you!

Okay I've forgotten to add web_module to the config. After added, it worked! However @conn.assigns[:locale] is sometimes nil I had to write if like this: <span><%= link "Register", to: Routes.pow_registration_path(@conn, :new, (if @conn.assigns[:locale], do: @conn.assigns[:locale], else: "fr")) %></span>

any idea @danschultzer ?

Thank you!

I just learnt that assings being reseted on every request which means I have to set the value every time. Modifying my plug.

You can make the plug much simpler since you can expect that locale is only defined in the path, and just set up fallback to default locale:

defmodule MyAppWeb.Plugs.Locale do
  import Plug.Conn

  @supported_locales Gettext.known_locales(MyAppWeb.Gettext)
  @default_locale "en"

  def init(opts), do: opts

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts)
      when locale in @supported_locales, do: set_locale(conn, locale)
  def call(conn, _opts), do: set_locale(conn, @default_locale)

  defp set_locale(conn, locale) do
     Gettext.put_locale(MyAppWeb.Gettext, locale)

    assign(conn, :locale, locale)
  end
end

But I highly recommend CLDR. That library has two plugs that can help you. As an example, you could put this in your endpoint:

plug Cldr.Plug.SetLocale,
  apps:    [:cldr, :gettext],
  from:    [:path, :cookie, :accept_language],
  param:   "locale",
  gettext: MyAppWeb.Gettext,
  cldr:    MyAppWeb.Cldr

This will first pull the locale from path, then cookie and last accept language. And then it'll set the locale for your Gettext and Cldr modules. I always use this library for any platforms where I need i18n 馃槃

Oh really? I saw CLDR from other issue, and I checked it out but it looks very complicated for a beginner and didn't had time to explore it. If the settings are just that simple then it's really cool! I'll try it! Thank you very much @danschultzer !

Yeah, it's intimidating since i18n can be very complicated. It's a really useful library to handle localization with little effort. The maintainer @kipcole9 is also very helpful.

I think it's underutilized in the Elixir community. It would be great if someone wrote a tutorial or made a walkthrough video for it to show the benefits of CLDR.

@danschultzer so even with fallback I can't make this request: http://localhost:4000/session/new while http://localhost:4000/en/session/new works.

no route found for GET /session/new (MyAppWeb.Router)

So with CLDR is that possible?

You'll need to ensure the routes without locale param also exists:

  scope "/:locale" do
    pipe_through :browser
    pow_routes()
    pow_extension_routes()
  end

  scope "/" do
    pipe_through :browser
    pow_routes()
    pow_extension_routes()
  end

Also you got an issue in your callback routes. You should call path_for/4 instead of url_for/4, and you should also override the url_for/4 method. There will likely be redirect errors otherwise.

If you use CLDR, you can do this:

defmodule MyAppWeb.Pow.Routes do
  use Pow.Phoenix.Routes

  @impl true
  def path_for(conn, plug, verb, vars, query_params) do
    vars = [MyAppWeb.Cldr.get_locale()] ++ vars

    Pow.Phoenix.Routes.path_for(conn, plug, verb, vars, query_params)
  end

  @impl true
  def url_for(conn, plug, verb, vars, query_params) do
    vars = [MyAppWeb.Cldr.get_locale()] ++ vars

    Pow.Phoenix.Routes.url_for(conn, plug, verb, vars, query_params)
  end
end

WOW! I did that part like this

  scope "/", MyAppWeb do
    pipe_through :browser
    pow_routes()
    pow_extension_routes()
  end

before but I was getting some weird errors but this time is okay; I shouldn't have bind to my Module. Okay. I got it!

Now all is good!

Thanks a lot!

Was this page helpful?
0 / 5 - 0 ratings