diff --git a/lib/lokal_web/controllers/email_controller.ex b/lib/lokal_web/controllers/email_controller.ex new file mode 100644 index 0000000..497cae0 --- /dev/null +++ b/lib/lokal_web/controllers/email_controller.ex @@ -0,0 +1,23 @@ +defmodule LokalWeb.EmailController do + @moduledoc """ + A dev controller used to develop on emails + """ + + use LokalWeb, :controller + alias Lokal.Accounts.User + + plug :put_layout, {LokalWeb.LayoutView, :email} + + @sample_assigns %{ + email: %{subject: "Example subject"}, + url: "https://lokal.bubbletea.dev/sample_url", + user: %User{email: "sample@email.com"} + } + + @doc """ + Debug route used to preview emails + """ + def preview(conn, %{"id" => template}) do + render(conn, "#{template |> to_string()}.html", @sample_assigns) + end +end diff --git a/lib/lokal_web/controllers/user_auth.ex b/lib/lokal_web/controllers/user_auth.ex index 57237d5..d9e918d 100644 --- a/lib/lokal_web/controllers/user_auth.ex +++ b/lib/lokal_web/controllers/user_auth.ex @@ -1,12 +1,13 @@ defmodule LokalWeb.UserAuth do @moduledoc """ - Module for any user authentication functions + Functions for user session and authentication """ import Plug.Conn import Phoenix.Controller import LokalWeb.Gettext alias Lokal.{Accounts, Accounts.User} + alias LokalWeb.PageLive alias LokalWeb.Router.Helpers, as: Routes # Make the remember me cookie valid for 60 days. @@ -32,6 +33,7 @@ defmodule LokalWeb.UserAuth do def log_in_user(conn, %User{confirmed_at: nil}, _params) do conn + |> fetch_flash() |> put_flash( :error, dgettext("errors", "You must confirm your account and log in to access this page.") @@ -53,6 +55,11 @@ defmodule LokalWeb.UserAuth do |> redirect(to: user_return_to || signed_in_path(conn)) end + @spec maybe_write_remember_me_cookie( + Plug.Conn.t(), + String.t() | any(), + %{required(String.t()) => String.t()} | any() + ) :: Plug.Conn.t() defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) end @@ -149,13 +156,31 @@ defmodule LokalWeb.UserAuth do conn else conn - |> put_flash(:error, "You must confirm your account and log in to access this page.") + |> put_flash( + :error, + dgettext("errors", "You must confirm your account and log in to access this page.") + ) |> maybe_store_return_to() |> redirect(to: Routes.user_session_path(conn, :new)) |> halt() end end + @doc """ + Used for routes that require the user to be an admin. + """ + def require_role(conn, role: role_atom) do + if conn.assigns[:current_user] && conn.assigns.current_user.role == role_atom do + conn + else + conn + |> put_flash(:error, dgettext("errors", "You are not authorized to view this page.")) + |> maybe_store_return_to() + |> redirect(to: Routes.live_path(conn, PageLive)) + |> halt() + end + end + defp maybe_store_return_to(%{method: "GET"} = conn) do put_session(conn, :user_return_to, current_path(conn)) end diff --git a/lib/lokal_web/controllers/user_confirmation_controller.ex b/lib/lokal_web/controllers/user_confirmation_controller.ex index 2b7883e..c03782f 100644 --- a/lib/lokal_web/controllers/user_confirmation_controller.ex +++ b/lib/lokal_web/controllers/user_confirmation_controller.ex @@ -1,10 +1,11 @@ defmodule LokalWeb.UserConfirmationController do use LokalWeb, :controller + import LokalWeb.Gettext alias Lokal.Accounts def new(conn, _params) do - render(conn, "new.html") + render(conn, "new.html", page_title: gettext("Confirm your account")) end def create(conn, %{"user" => %{"email" => email}}) do @@ -19,8 +20,11 @@ defmodule LokalWeb.UserConfirmationController do conn |> put_flash( :info, - "If your email is in our system and it has not been confirmed yet, " <> - "you will receive an email with instructions shortly." + dgettext( + "prompts", + "If your email is in our system and it has not been confirmed yet, " <> + "you will receive an email with instructions shortly." + ) ) |> redirect(to: "/") end @@ -29,9 +33,9 @@ defmodule LokalWeb.UserConfirmationController do # leaked token giving the user access to the account. def confirm(conn, %{"token" => token}) do case Accounts.confirm_user(token) do - {:ok, _} -> + {:ok, %{email: email}} -> conn - |> put_flash(:info, "User confirmed successfully.") + |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email)) |> redirect(to: "/") :error -> @@ -45,7 +49,10 @@ defmodule LokalWeb.UserConfirmationController do %{} -> conn - |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> put_flash( + :error, + dgettext("errors", "User confirmation link is invalid or it has expired.") + ) |> redirect(to: "/") end end diff --git a/lib/lokal_web/controllers/user_registration_controller.ex b/lib/lokal_web/controllers/user_registration_controller.ex index dcc7041..584516e 100644 --- a/lib/lokal_web/controllers/user_registration_controller.ex +++ b/lib/lokal_web/controllers/user_registration_controller.ex @@ -1,30 +1,81 @@ defmodule LokalWeb.UserRegistrationController do use LokalWeb, :controller - - alias Lokal.Accounts + import LokalWeb.Gettext + alias Lokal.{Accounts, Invites} alias Lokal.Accounts.User - alias LokalWeb.UserAuth + alias LokalWeb.{Endpoint, PageLive} - def new(conn, _params) do - changeset = Accounts.change_user_registration(%User{}) - render(conn, "new.html", changeset: changeset) + def new(conn, %{"invite" => invite_token}) do + invite = Invites.get_invite_by_token(invite_token) + + if invite do + conn |> render_new(invite) + else + conn + |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) + |> redirect(to: Routes.live_path(Endpoint, PageLive)) + end end - def create(conn, %{"user" => user_params}) do + def new(conn, _params) do + if Accounts.allow_registration?() do + conn |> render_new() + else + conn + |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) + |> redirect(to: Routes.live_path(Endpoint, PageLive)) + end + end + + # renders new user registration page + defp render_new(conn, invite \\ nil) do + render(conn, "new.html", + changeset: Accounts.change_user_registration(%User{}), + invite: invite, + page_title: gettext("Register") + ) + end + + def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do + invite = Invites.get_invite_by_token(invite_token) + + if invite do + conn |> create_user(attrs, invite) + else + conn + |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) + |> redirect(to: Routes.live_path(Endpoint, PageLive)) + end + end + + def create(conn, attrs) do + if Accounts.allow_registration?() do + conn |> create_user(attrs) + else + conn + |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) + |> redirect(to: Routes.live_path(Endpoint, PageLive)) + end + end + + defp create_user(conn, %{"user" => user_params}, invite \\ nil) do case Accounts.register_user(user_params) do {:ok, user} -> - {:ok, _} = - Accounts.deliver_user_confirmation_instructions( - user, - &Routes.user_confirmation_url(conn, :confirm, &1) - ) + unless invite |> is_nil() do + invite |> Invites.use_invite!() + end + + Accounts.deliver_user_confirmation_instructions( + user, + &Routes.user_confirmation_url(conn, :confirm, &1) + ) conn - |> put_flash(:info, "User created successfully.") - |> UserAuth.log_in_user(user) + |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) + |> redirect(to: Routes.user_session_path(Endpoint, :new)) {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) + conn |> render("new.html", changeset: changeset, invite: invite) end end end diff --git a/lib/lokal_web/controllers/user_reset_password_controller.ex b/lib/lokal_web/controllers/user_reset_password_controller.ex index 5260212..0028bda 100644 --- a/lib/lokal_web/controllers/user_reset_password_controller.ex +++ b/lib/lokal_web/controllers/user_reset_password_controller.ex @@ -6,7 +6,7 @@ defmodule LokalWeb.UserResetPasswordController do plug :get_user_by_reset_password_token when action in [:edit, :update] def new(conn, _params) do - render(conn, "new.html") + render(conn, "new.html", page_title: gettext("Forgot your password?")) end def create(conn, %{"user" => %{"email" => email}}) do @@ -21,13 +21,20 @@ defmodule LokalWeb.UserResetPasswordController do conn |> put_flash( :info, - "If your email is in our system, you will receive instructions to reset your password shortly." + dgettext( + "prompts", + "If your email is in our system, you will receive instructions to " <> + "reset your password shortly." + ) ) |> redirect(to: "/") end def edit(conn, _params) do - render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) + render(conn, "edit.html", + changeset: Accounts.change_user_password(conn.assigns.user), + page_title: gettext("Reset your password") + ) end # Do not log in the user after reset password to avoid a @@ -36,7 +43,7 @@ defmodule LokalWeb.UserResetPasswordController do case Accounts.reset_user_password(conn.assigns.user, user_params) do {:ok, _} -> conn - |> put_flash(:info, "Password reset successfully.") + |> put_flash(:info, dgettext("prompts", "Password reset successfully.")) |> redirect(to: Routes.user_session_path(conn, :new)) {:error, changeset} -> @@ -51,7 +58,10 @@ defmodule LokalWeb.UserResetPasswordController do conn |> assign(:user, user) |> assign(:token, token) else conn - |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> put_flash( + :error, + dgettext("errors", "Reset password link is invalid or it has expired.") + ) |> redirect(to: "/") |> halt() end diff --git a/lib/lokal_web/controllers/user_session_controller.ex b/lib/lokal_web/controllers/user_session_controller.ex index e288c73..23b6b84 100644 --- a/lib/lokal_web/controllers/user_session_controller.ex +++ b/lib/lokal_web/controllers/user_session_controller.ex @@ -5,7 +5,7 @@ defmodule LokalWeb.UserSessionController do alias LokalWeb.UserAuth def new(conn, _params) do - render(conn, "new.html", error_message: nil) + render(conn, "new.html", error_message: nil, page_title: gettext("Log in")) end def create(conn, %{"user" => user_params}) do @@ -14,13 +14,13 @@ defmodule LokalWeb.UserSessionController do if user = Accounts.get_user_by_email_and_password(email, password) do UserAuth.log_in_user(conn, user, user_params) else - render(conn, "new.html", error_message: "Invalid email or password") + render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password")) end end def delete(conn, _params) do conn - |> put_flash(:info, "Logged out successfully.") + |> put_flash(:info, dgettext("prompts", "Logged out successfully.")) |> UserAuth.log_out_user() end end diff --git a/lib/lokal_web/controllers/user_settings_controller.ex b/lib/lokal_web/controllers/user_settings_controller.ex index 8a912ea..9c153a2 100644 --- a/lib/lokal_web/controllers/user_settings_controller.ex +++ b/lib/lokal_web/controllers/user_settings_controller.ex @@ -1,13 +1,13 @@ defmodule LokalWeb.UserSettingsController do use LokalWeb, :controller - + import LokalWeb.Gettext alias Lokal.Accounts - alias LokalWeb.UserAuth + alias LokalWeb.{PageLive, UserAuth} plug :assign_email_and_password_changesets def edit(conn, _params) do - render(conn, "edit.html") + render(conn, "edit.html", page_title: gettext("Settings")) end def update(conn, %{"action" => "update_email"} = params) do @@ -25,7 +25,10 @@ defmodule LokalWeb.UserSettingsController do conn |> put_flash( :info, - "A link to confirm your email change has been sent to the new address." + dgettext( + "prompts", + "A link to confirm your email change has been sent to the new address." + ) ) |> redirect(to: Routes.user_settings_path(conn, :edit)) @@ -41,7 +44,7 @@ defmodule LokalWeb.UserSettingsController do case Accounts.update_user_password(user, password, user_params) do {:ok, user} -> conn - |> put_flash(:info, "Password updated successfully.") + |> put_flash(:info, dgettext("prompts", "Password updated successfully.")) |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) |> UserAuth.log_in_user(user) @@ -54,16 +57,33 @@ defmodule LokalWeb.UserSettingsController do case Accounts.update_user_email(conn.assigns.current_user, token) do :ok -> conn - |> put_flash(:info, "Email changed successfully.") + |> put_flash(:info, dgettext("prompts", "Email changed successfully.")) |> redirect(to: Routes.user_settings_path(conn, :edit)) :error -> conn - |> put_flash(:error, "Email change link is invalid or it has expired.") + |> put_flash( + :error, + dgettext("errors", "Email change link is invalid or it has expired.") + ) |> redirect(to: Routes.user_settings_path(conn, :edit)) end end + def delete(%{assigns: %{current_user: current_user}} = conn, %{"id" => user_id}) do + if user_id == current_user.id do + current_user |> Accounts.delete_user!(current_user) + + conn + |> put_flash(:error, dgettext("prompts", "Your account has been deleted")) + |> redirect(to: Routes.live_path(conn, PageLive)) + else + conn + |> put_flash(:error, dgettext("errors", "Unable to delete user")) + |> redirect(to: Routes.user_settings_path(conn, :edit)) + end + end + defp assign_email_and_password_changesets(conn, _opts) do user = conn.assigns.current_user diff --git a/lib/lokal_web/views/error_helpers.ex b/lib/lokal_web/views/error_helpers.ex index 6f1771c..aa76578 100644 --- a/lib/lokal_web/views/error_helpers.ex +++ b/lib/lokal_web/views/error_helpers.ex @@ -4,22 +4,31 @@ defmodule LokalWeb.ErrorHelpers do """ use Phoenix.HTML + import Phoenix.LiveView.Helpers + alias Ecto.Changeset + alias Phoenix.{HTML.Form, LiveView.Rendered} @doc """ Generates tag for inlined form input errors. """ - def error_tag(form, field) do - Enum.map(Keyword.get_values(form.errors, field), fn error -> - content_tag(:span, translate_error(error), - class: "invalid-feedback", - phx_feedback_for: input_name(form, field) - ) - end) + @spec error_tag(Form.t(), Form.field()) :: Rendered.t() + @spec error_tag(Form.t(), Form.field(), String.t()) :: Rendered.t() + def error_tag(form, field, extra_class \\ "") do + assigns = %{extra_class: extra_class, form: form, field: field} + + ~H""" + <%= for error <- Keyword.get_values(@form.errors, @field) do %> + + <%= translate_error(error) %> + + <% end %> + """ end @doc """ Translates an error message using gettext. """ + @spec translate_error({String.t(), keyword() | map()}) :: String.t() def translate_error({msg, opts}) do # When using gettext, we typically pass the strings we want # to translate as a static argument: @@ -44,4 +53,30 @@ defmodule LokalWeb.ErrorHelpers do Gettext.dgettext(LokalWeb.Gettext, "errors", msg, opts) end end + + @doc """ + Displays all errors from a changeset, or just for a single key + """ + @spec changeset_errors(Changeset.t()) :: String.t() + @spec changeset_errors(Changeset.t(), key :: atom()) :: [String.t()] | nil + def changeset_errors(changeset) do + changeset + |> changeset_error_map() + |> Enum.map_join(". ", fn {key, errors} -> + "#{key |> humanize()}: #{errors |> Enum.join(", ")}" + end) + end + + def changeset_errors(changeset, key) do + changeset |> changeset_error_map() |> Map.get(key) + end + + @doc """ + Displays all errors from a changeset in a key value map + """ + @spec changeset_error_map(Changeset.t()) :: %{atom() => [String.t()]} + def changeset_error_map(changeset) do + changeset + |> Changeset.traverse_errors(fn error -> error |> translate_error() end) + end end diff --git a/lib/lokal_web/views/error_view.ex b/lib/lokal_web/views/error_view.ex index 8d3af60..bca829e 100644 --- a/lib/lokal_web/views/error_view.ex +++ b/lib/lokal_web/views/error_view.ex @@ -1,16 +1,16 @@ defmodule LokalWeb.ErrorView do use LokalWeb, :view + import LokalWeb.Components.Topbar + alias LokalWeb.{Endpoint, PageLive} - # If you want to customize a particular status code - # for a certain format, you may uncomment below. - # def render("500.html", _assigns) do - # "Internal Server Error" - # end + def template_not_found(error_path, _assigns) do + error_string = + case error_path do + "404.html" -> dgettext("errors", "Not found") + "401.html" -> dgettext("errors", "Unauthorized") + _ -> dgettext("errors", "Internal Server Error") + end - # By default, Phoenix returns the status message from - # the template name. For example, "404.html" becomes - # "Not Found". - def template_not_found(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) + render("error.html", %{error_string: error_string}) end end diff --git a/lib/lokal_web/views/user_confirmation_view.ex b/lib/lokal_web/views/user_confirmation_view.ex index d3706b0..c9ed551 100644 --- a/lib/lokal_web/views/user_confirmation_view.ex +++ b/lib/lokal_web/views/user_confirmation_view.ex @@ -1,3 +1,4 @@ defmodule LokalWeb.UserConfirmationView do use LokalWeb, :view + alias Lokal.Accounts end diff --git a/lib/lokal_web/views/user_reset_password_view.ex b/lib/lokal_web/views/user_reset_password_view.ex index 68cd504..6232dc4 100644 --- a/lib/lokal_web/views/user_reset_password_view.ex +++ b/lib/lokal_web/views/user_reset_password_view.ex @@ -1,3 +1,4 @@ defmodule LokalWeb.UserResetPasswordView do use LokalWeb, :view + alias Lokal.Accounts end diff --git a/lib/lokal_web/views/user_session_view.ex b/lib/lokal_web/views/user_session_view.ex index f61a2ae..bb9bf6f 100644 --- a/lib/lokal_web/views/user_session_view.ex +++ b/lib/lokal_web/views/user_session_view.ex @@ -1,3 +1,4 @@ defmodule LokalWeb.UserSessionView do use LokalWeb, :view + alias Lokal.Accounts end diff --git a/lib/lokal_web/views/view_helpers.ex b/lib/lokal_web/views/view_helpers.ex index 28b6f44..889ca3b 100644 --- a/lib/lokal_web/views/view_helpers.ex +++ b/lib/lokal_web/views/view_helpers.ex @@ -1,7 +1,8 @@ defmodule LokalWeb.ViewHelpers do @moduledoc """ Contains common helpers that can be used in liveviews and regular views. These - are automatically imported into any Phoenix View using `use LokalWeb, :view` + are automatically imported into any Phoenix View using `use LokalWeb, + :view` """ import Phoenix.LiveView.Helpers @@ -10,24 +11,42 @@ defmodule LokalWeb.ViewHelpers do Returns a