diff --git a/config/config.exs b/config/config.exs index 99cfae1..81fda3e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -67,6 +67,14 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :basket, :pow, + web_mailer_module: BasketWeb, + user: Basket.Users.User, + repo: Basket.Repo, + extensions: [PowResetPassword, PowEmailConfirmation], + controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks, + mailer: MyAppWeb.Pow.Mailer + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/basket/users/user.ex b/lib/basket/users/user.ex new file mode 100644 index 0000000..114c862 --- /dev/null +++ b/lib/basket/users/user.ex @@ -0,0 +1,21 @@ +defmodule Basket.Users.User do + @moduledoc false + + use Ecto.Schema + use Pow.Ecto.Schema + + use Pow.Extension.Ecto.Schema, + extensions: [PowResetPassword, PowEmailConfirmation] + + def changeset(user_or_changeset, attrs) do + user_or_changeset + |> pow_changeset(attrs) + |> pow_extension_changeset(attrs) + end + + schema "users" do + pow_user_fields() + + timestamps() + end +end diff --git a/lib/basket_web.ex b/lib/basket_web.ex index b5c3a52..c13ca52 100644 --- a/lib/basket_web.ex +++ b/lib/basket_web.ex @@ -19,6 +19,14 @@ defmodule BasketWeb do def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + def mail do + quote do + use Pow.Phoenix.Mailer.Component + + unquote(html_helpers()) + end + end + def router do quote do use Phoenix.Router, helpers: false diff --git a/lib/basket_web/controllers/pow_invitation/invitation_html.ex b/lib/basket_web/controllers/pow_invitation/invitation_html.ex new file mode 100644 index 0000000..598601f --- /dev/null +++ b/lib/basket_web/controllers/pow_invitation/invitation_html.ex @@ -0,0 +1,5 @@ +defmodule BasketWeb.PowInvitation.InvitationHTML do + use BasketWeb, :html + + embed_templates "invitation_html/*" +end diff --git a/lib/basket_web/controllers/pow_invitation/invitation_html/edit.html.heex b/lib/basket_web/controllers/pow_invitation/invitation_html/edit.html.heex new file mode 100644 index 0000000..d296a9c --- /dev/null +++ b/lib/basket_web/controllers/pow_invitation/invitation_html/edit.html.heex @@ -0,0 +1,35 @@ +
+ <.header class="text-center"> + Register + <:subtitle> + Already have an account? + <.link + navigate={Pow.Phoenix.Routes.path_for(@conn, Pow.Phoenix.SessionController, :new)} + class="font-semibold text-brand hover:underline" + > + Sign in + + now. + + + + <.simple_form :let={f} for={@changeset} as={:user} action={@action} phx-update="ignore"> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input + field={f[Pow.Ecto.Schema.user_id_field(@changeset)]} + type={(Pow.Ecto.Schema.user_id_field(@changeset) == :email && "email") || "text"} + label={Phoenix.Naming.humanize(Pow.Ecto.Schema.user_id_field(@changeset))} + required + /> + <.input field={f[:password]} type="password" label="Password" required /> + <.input field={f[:password_confirmation]} type="password" label="Confirm password" required /> + + <:actions> + <.button phx-disable-with="Submitting..." class="w-full"> + Submit + + + +
diff --git a/lib/basket_web/controllers/pow_invitation/invitation_html/new.html.heex b/lib/basket_web/controllers/pow_invitation/invitation_html/new.html.heex new file mode 100644 index 0000000..3aad1ce --- /dev/null +++ b/lib/basket_web/controllers/pow_invitation/invitation_html/new.html.heex @@ -0,0 +1,23 @@ +
+ <.header class="text-center"> + Invite + + + <.simple_form :let={f} for={@changeset} as={:user} action={@action} phx-update="ignore"> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input + field={f[Pow.Ecto.Schema.user_id_field(@changeset)]} + type={(Pow.Ecto.Schema.user_id_field(@changeset) == :email && "email") || "text"} + label={Phoenix.Naming.humanize(Pow.Ecto.Schema.user_id_field(@changeset))} + required + /> + + <:actions> + <.button phx-disable-with="Submitting..." class="w-full"> + Submit + + + +
diff --git a/lib/basket_web/controllers/pow_invitation/invitation_html/show.html.heex b/lib/basket_web/controllers/pow_invitation/invitation_html/show.html.heex new file mode 100644 index 0000000..bb0aac1 --- /dev/null +++ b/lib/basket_web/controllers/pow_invitation/invitation_html/show.html.heex @@ -0,0 +1,42 @@ +
+ <.header class="text-center"> + Invitation URL + <:subtitle> + Please send the following URL to the invitee. + + + +
+
+ <.input name="invite-url" type="text" id="invite-url" value={@url} class="mt-0" readonly /> + <.button + phx-click={JS.dispatch("phx:share", to: "#invite-url")} + aria-label="Share" + class="mt-2" + > + <.icon name="hero-arrow-up-on-square" class="w-6 h-6" /> + +
+
+
+ diff --git a/lib/basket_web/controllers/pow_reset_password/reset_password_html.ex b/lib/basket_web/controllers/pow_reset_password/reset_password_html.ex new file mode 100644 index 0000000..d2b2766 --- /dev/null +++ b/lib/basket_web/controllers/pow_reset_password/reset_password_html.ex @@ -0,0 +1,5 @@ +defmodule BasketWeb.PowResetPassword.ResetPasswordHTML do + use BasketWeb, :html + + embed_templates "reset_password_html/*" +end diff --git a/lib/basket_web/controllers/pow_reset_password/reset_password_html/edit.html.heex b/lib/basket_web/controllers/pow_reset_password/reset_password_html/edit.html.heex new file mode 100644 index 0000000..657d458 --- /dev/null +++ b/lib/basket_web/controllers/pow_reset_password/reset_password_html/edit.html.heex @@ -0,0 +1,34 @@ +
+ <.header class="text-center"> + Reset password + <:subtitle> + Know your password? + <.link + navigate={Pow.Phoenix.Routes.path_for(@conn, Pow.Phoenix.SessionController, :new)} + class="font-semibold text-brand hover:underline" + > + Sign in + + now. + + + + <.simple_form :let={f} for={@changeset} as={:user} action={@action} phx-update="ignore"> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:password]} type="password" label="New password" required /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + + <:actions> + <.button phx-disable-with="Submitting..." class="w-full"> + Submit + + + +
diff --git a/lib/basket_web/controllers/pow_reset_password/reset_password_html/new.html.heex b/lib/basket_web/controllers/pow_reset_password/reset_password_html/new.html.heex new file mode 100644 index 0000000..460d71e --- /dev/null +++ b/lib/basket_web/controllers/pow_reset_password/reset_password_html/new.html.heex @@ -0,0 +1,28 @@ +
+ <.header class="text-center"> + Reset password + <:subtitle> + Know your password? + <.link + navigate={Pow.Phoenix.Routes.path_for(@conn, Pow.Phoenix.SessionController, :new)} + class="font-semibold text-brand hover:underline" + > + Sign in + + now. + + + + <.simple_form :let={f} for={@changeset} as={:user} action={@action} phx-update="ignore"> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:email]} type="email" label="Email" required /> + + <:actions> + <.button phx-disable-with="Submitting..." class="w-full"> + Submit + + + +
diff --git a/lib/basket_web/endpoint.ex b/lib/basket_web/endpoint.ex index e024cb8..c0b8728 100644 --- a/lib/basket_web/endpoint.ex +++ b/lib/basket_web/endpoint.ex @@ -47,5 +47,6 @@ defmodule BasketWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options + plug Pow.Plug.Session, otp_app: :basket plug BasketWeb.Router end diff --git a/lib/basket_web/mails/pow/mailer.ex b/lib/basket_web/mails/pow/mailer.ex new file mode 100644 index 0000000..3ca4888 --- /dev/null +++ b/lib/basket_web/mails/pow/mailer.ex @@ -0,0 +1,20 @@ +defmodule MyAppWeb.Pow.Mailer do + @moduledoc """ + Stub mailer implementation for initial Pow config + """ + + use Pow.Phoenix.Mailer + require Logger + + def cast(%{user: user, subject: subject, text: text, html: html, assigns: _assigns}) do + # Build email struct to be used in `process/1` + + %{to: user.email, subject: subject, text: text, html: html} + end + + def process(email) do + # Send email + + Logger.debug("E-mail sent: #{inspect(email)}") + end +end diff --git a/lib/basket_web/mails/pow_email_confirmation_mail.ex b/lib/basket_web/mails/pow_email_confirmation_mail.ex new file mode 100644 index 0000000..10663fd --- /dev/null +++ b/lib/basket_web/mails/pow_email_confirmation_mail.ex @@ -0,0 +1,23 @@ +defmodule BasketWeb.PowEmailConfirmationMail do + @moduledoc false + + use BasketWeb, :mail + + def email_confirmation(assigns) do + %Pow.Phoenix.Mailer.Template{ + subject: "Confirm your email address", + html: ~H""" +

Hi

+

Please use the following link to confirm your e-mail address:

+

{@url}

+ """, + text: ~P""" + Hi, + + Please use the following link to confirm your e-mail address: + + <%= @url %> + """ + } + end +end diff --git a/lib/basket_web/mails/pow_invitation_mail.ex b/lib/basket_web/mails/pow_invitation_mail.ex new file mode 100644 index 0000000..bb39415 --- /dev/null +++ b/lib/basket_web/mails/pow_invitation_mail.ex @@ -0,0 +1,25 @@ +defmodule BasketWeb.PowInvitationMail do + @moduledoc false + + use BasketWeb, :mail + + def invitation(assigns) do + %Pow.Phoenix.Mailer.Template{ + subject: "You've been invited", + html: ~H""" +

Hi,

+

+ You've been invited by <%= @invited_by_user_id %>. Please use the following link to accept your invitation: +

+

{@url}

+ """, + text: ~P""" + Hi, + + You've been invited by <%= @invited_by_user_id %>. Please use the following link to accept your invitation: + + {@url} + """ + } + end +end diff --git a/lib/basket_web/mails/pow_reset_password_mail.ex b/lib/basket_web/mails/pow_reset_password_mail.ex new file mode 100644 index 0000000..e53330c --- /dev/null +++ b/lib/basket_web/mails/pow_reset_password_mail.ex @@ -0,0 +1,26 @@ +defmodule BasketWeb.PowResetPasswordMail do + @moduledoc false + + use BasketWeb, :mail + + def reset_password(assigns) do + %Pow.Phoenix.Mailer.Template{ + subject: "Reset password link", + html: ~H""" +

Hi,

+

Please use the following link to reset your password:

+

<%= @url %>

+

You can disregard this email if you didn't request a password reset.

+ """, + text: ~P""" + Hi, + + Please use the following link to reset your password: + + <%= @url %> + + You can disregard this email if you didn't request a password reset. + """ + } + end +end diff --git a/lib/basket_web/router.ex b/lib/basket_web/router.ex index cd7190d..9052667 100644 --- a/lib/basket_web/router.ex +++ b/lib/basket_web/router.ex @@ -1,5 +1,9 @@ defmodule BasketWeb.Router do use BasketWeb, :router + use Pow.Phoenix.Router + + use Pow.Extension.Phoenix.Router, + extensions: [PowResetPassword, PowEmailConfirmation, PowInvitation, PowPersistentSession] import Surface.Catalogue.Router @@ -16,6 +20,13 @@ defmodule BasketWeb.Router do plug :accepts, ["json"] end + scope "/" do + pipe_through :browser + + pow_routes() + pow_extension_routes() + end + scope "/", BasketWeb do pipe_through :browser diff --git a/mix.exs b/mix.exs index 650cb20..b7acabf 100644 --- a/mix.exs +++ b/mix.exs @@ -66,14 +66,15 @@ defmodule Basket.MixProject do {:dns_cluster, "~> 0.1.1"}, {:plug_cowboy, "~> 2.5"}, {:surface, "~> 0.11.0"}, - # for surface.init + # for surface.init; possible to remove. {:sourceror, "~> 0.12.0"}, {:surface_catalogue, "~> 0.6.0"}, {:excoveralls, "~> 0.18", only: :test}, {:sobelow, "~> 0.13.0", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7.1", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4.2", runtime: false}, - {:mix_audit, "~> 2.1.1", runtime: false} + {:mix_audit, "~> 2.1.1", runtime: false}, + {:pow, "~> 1.0.34"} ] end diff --git a/mix.lock b/mix.lock index 81e1928..bf9cce3 100644 --- a/mix.lock +++ b/mix.lock @@ -43,6 +43,7 @@ "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, + "pow": {:hex, :pow, "1.0.34", "51999e624475a4c75d9e5d04fcf7e38b3c5a1f8d09f37c1311d7bef43962aafa", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0 and < 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and < 4.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "63a5e3b5197a39ac0320224526fb555b2b009852d878d29efc4362537393080b"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "sourceror": {:hex, :sourceror, "0.12.3", "a2ad3a1a4554b486d8a113ae7adad5646f938cad99bf8bfcef26dc0c88e8fade", [:mix], [], "hexpm", "4d4e78010ca046524e8194ffc4683422f34a96f6b82901abbb45acc79ace0316"}, diff --git a/priv/repo/migrations/20231031183908_create_users.exs b/priv/repo/migrations/20231031183908_create_users.exs new file mode 100644 index 0000000..e49e54e --- /dev/null +++ b/priv/repo/migrations/20231031183908_create_users.exs @@ -0,0 +1,14 @@ +defmodule Basket.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :email, :string, null: false + add :password_hash, :string + + timestamps() + end + + create unique_index(:users, [:email]) + end +end diff --git a/priv/repo/migrations/20231031184704_add_pow_email_confirmation_to_users.exs b/priv/repo/migrations/20231031184704_add_pow_email_confirmation_to_users.exs new file mode 100644 index 0000000..0364ff7 --- /dev/null +++ b/priv/repo/migrations/20231031184704_add_pow_email_confirmation_to_users.exs @@ -0,0 +1,13 @@ +defmodule Basket.Repo.Migrations.AddPowEmailConfirmationToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :email_confirmation_token, :string + add :email_confirmed_at, :utc_datetime + add :unconfirmed_email, :string + end + + create unique_index(:users, [:email_confirmation_token]) + end +end diff --git a/priv/repo/migrations/20231031184705_add_pow_invitation_to_users.exs b/priv/repo/migrations/20231031184705_add_pow_invitation_to_users.exs new file mode 100644 index 0000000..9162e69 --- /dev/null +++ b/priv/repo/migrations/20231031184705_add_pow_invitation_to_users.exs @@ -0,0 +1,13 @@ +defmodule Basket.Repo.Migrations.AddPowInvitationToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :invitation_token, :string + add :invitation_accepted_at, :utc_datetime + add :invited_by_id, references("users", on_delete: :nothing) + end + + create unique_index(:users, [:invitation_token]) + end +end