diff --git a/lib/maker_passport/accounts/user.ex b/lib/maker_passport/accounts/user.ex index 622f226..c84e4a9 100644 --- a/lib/maker_passport/accounts/user.ex +++ b/lib/maker_passport/accounts/user.ex @@ -10,6 +10,7 @@ defmodule MakerPassport.Accounts.User do schema "users" do field :email, :string + field :role, :string, default: "user" field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true field :current_password, :string, virtual: true, redact: true @@ -45,7 +46,7 @@ defmodule MakerPassport.Accounts.User do """ def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :password]) + |> cast(attrs, [:email, :password, :role]) |> validate_email(opts) |> validate_password(opts) |> cast_assoc(:profile) diff --git a/lib/maker_passport/accounts/user_notifier.ex b/lib/maker_passport/accounts/user_notifier.ex index 689573e..8c9f637 100644 --- a/lib/maker_passport/accounts/user_notifier.ex +++ b/lib/maker_passport/accounts/user_notifier.ex @@ -4,6 +4,21 @@ defmodule MakerPassport.Accounts.UserNotifier do alias MakerPassport.Mailer + # Delivers the email using the application mailer. + defp deliver(recipient, reply_to, subject, body) do + email = + new() + |> to(recipient) + |> reply_to(reply_to) + |> from({"IOP: Maker Passport", "no-reply@internetofproduction.org"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + # Delivers the email using the application mailer. defp deliver(recipient, subject, body) do email = @@ -79,4 +94,32 @@ defmodule MakerPassport.Accounts.UserNotifier do ============================== """) end + + + @doc """ + Deliver instructions to update a user email. + """ + def confirm_email(visitor, url) do + deliver(visitor.email, "Confirm email", """ + + ============================== + + Hi #{visitor.name}, + + You can confirm your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def send_email_to_maker(email_params) do + deliver(email_params.maker_email, email_params.sender_email, email_params.subject, email_params.body) + end end diff --git a/lib/maker_passport/authorization.ex b/lib/maker_passport/authorization.ex new file mode 100644 index 0000000..2667589 --- /dev/null +++ b/lib/maker_passport/authorization.ex @@ -0,0 +1,21 @@ +defmodule MakerPassport.Permissions do + @moduledoc """ + Provides permission checks for different user roles. + """ + use Permit.Permissions, actions_module: Permit.Phoenix.Actions + + def can(%{role: "admin"}) do + permit() + |> all(MakerPassport.Visitors.Visitor) + |> all(MakerPassport.Profile) + end + + def can(_), do: permit() +end + +defmodule MakerPassport.Authorization do + @moduledoc """ + Handles authorization logic using defined permissions. + """ + use Permit, permissions_module: MakerPassport.Permissions +end diff --git a/lib/maker_passport/maker.ex b/lib/maker_passport/maker.ex index 0dc4542..890576e 100644 --- a/lib/maker_passport/maker.ex +++ b/lib/maker_passport/maker.ex @@ -19,13 +19,11 @@ defmodule MakerPassport.Maker do """ def list_profiles(skills \\ []) do - Repo.all(Profile) |> Repo.preload([:skills, :user]) - query = Profile |> join(:left, [p], s in assoc(p, :skills)) |> maybe_filter_by_skills(skills) - |> preload([:skills, :user]) + |> preload([:skills, :user, :location]) |> distinct([p], p.id) Repo.all(query) @@ -364,6 +362,13 @@ defmodule MakerPassport.Maker do {location.city, location.id} end + def get_country_name(country_code) do + case Countries.get(country_code) do + nil -> "Unknown" + country -> country.name + end + end + @doc """ Gets a single website. diff --git a/lib/maker_passport/visitor.ex b/lib/maker_passport/visitor.ex new file mode 100644 index 0000000..8d442f3 --- /dev/null +++ b/lib/maker_passport/visitor.ex @@ -0,0 +1,205 @@ +defmodule MakerPassport.Visitor do + import Ecto.Query, warn: false + alias MakerPassport.Accounts + alias MakerPassport.Repo + + alias MakerPassport.Visitors.{Visitor, Email} + + @doc """ + Gets a visitor by id. + + ## Examples + + iex> get_visitor!(1) + {:ok, %Visitor{}} + + iex> get_visitor!(456) + nil + + """ + def get_visitor!(id) do + Repo.get!(Visitor, id) + end + + @doc """ + Gets a visitor by token. + + ## Examples + + iex> get_visitor_by_token("token") + {:ok, %Visitor{}} + + iex> get_visitor_by_token("bad_token") + nil + + """ + def get_visitor_by_token(token) do + token = Visitor.decode_token(token) + + Repo.one( + from v in Visitor, + where: + v.token == ^token and + v.updated_at >= ^NaiveDateTime.add(NaiveDateTime.utc_now(), -7 * 24 * 60 * 60), + select: v + ) + end + + @doc """ + Gets a visitor by email. + + ## Examples + + iex> get_visitor_by_email("email") + {:ok, %Visitor{}} + + iex> get_visitor_by_email("bad_email") + nil + + """ + def get_visitor_by_email(email) do + Repo.get_by(Visitor, email: email) + end + + @doc """ + Creates a verify visitor. + + ## Examples + + iex> create_and_verify_visitor(%{field: value}) + {:ok, %Visitor{}} + + iex> create_and_verify_visitor(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_and_verify_visitor(attrs \\ %{}) do + {encoded_token, hashed_token} = Visitor.generate_token() + + visitor = + %Visitor{} + |> Map.put(:token, hashed_token) + |> Visitor.changeset(attrs) + |> Repo.insert!() + + url = MakerPassportWeb.Endpoint.url() <> "/verify-email?token=#{encoded_token}" + + Accounts.UserNotifier.confirm_email(visitor, url) + + {:ok, visitor} + end + + @doc """ + Updates a visitor. + + ## Examples + + iex> update_visitor(%Visitor{}, %{is_verified: true}) + {:ok, %Visitor{}} + + iex> update_visitor(%Visitor{}, %{is_verified: false}) + {:error, %Ecto.Changeset{}} + + """ + def update_visitor(%Visitor{} = visitor, attrs) do + visitor + |> Visitor.changeset(attrs) + |> Repo.update() + end + + @doc """ + Updates a visitor and verifies it. + + ## Examples + + iex> update_and_verify_visitor(%Visitor{}, %{field: value}) + {:ok, %Visitor{}} + + iex> update_and_verify_visitor(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_and_verify_visitor(%Visitor{} = visitor, attrs \\ %{}) do + {encoded_token, hashed_token} = Visitor.generate_token() + attrs = Map.put(attrs, "token", hashed_token) + + {:ok, visitor} = update_visitor(visitor, attrs) + + url = MakerPassportWeb.Endpoint.url() <> "/verify-email?token=#{encoded_token}" + + Accounts.UserNotifier.confirm_email(visitor, url) + + {:ok, visitor} + end + + @doc """ + List unverified visitors. + + ## Examples + + iex> list_unverified_visitors() + [%Visitor{}, ...] + + """ + def list_visitors(filters \\ %{}) do + Visitor + |> filter_by_status(filters) + |> order_by([v], asc: v.is_verified) + |> Repo.all() + end + + def filter_by_status(query, %{"status" => "Email verified"}) do + from v in query, where: v.is_verified == true + end + + def filter_by_status(query, %{"status" => "Email unverified"}) do + from v in query, where: v.is_verified == false + end + + def filter_by_status(query, _), do: query + + @doc """ + Creates a email. + + ## Examples + + iex> create_email(%{field: value}) + {:ok, %Email{}} + + iex> create_email(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_email(attrs \\ %{}) do + %Email{} + |> Email.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Get emails by visitor id. + + ## Examples + + iex> get_emails_by_visitor_id(visitor_id) + [%Email{}, ...] + + """ + def list_emails_of_a_visitor(visitor_id) do + Repo.all(from e in Email, where: e.visitor_id == ^visitor_id) + |> Repo.preload(profile: [:user]) + end + + @doc """ + Delete emails by visitor id. + + ## Examples + + iex> delete_emails_of_a_visitor(visitor_id) + [%Email{}, ...] + + """ + def delete_emails_of_a_visitor(visitor_id) do + Repo.delete_all(from e in Email, where: e.visitor_id == ^visitor_id) + end +end diff --git a/lib/maker_passport/visitors/email.ex b/lib/maker_passport/visitors/email.ex new file mode 100644 index 0000000..8373ec7 --- /dev/null +++ b/lib/maker_passport/visitors/email.ex @@ -0,0 +1,24 @@ +defmodule MakerPassport.Visitors.Email do + @moduledoc """ + A Maker's profile location. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "emails" do + field :subject, :string + field :body, :string + belongs_to :profile, MakerPassport.Maker.Profile + belongs_to :visitor, MakerPassport.Visitors.Visitor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(email, attrs \\ %{}) do + email + |> cast(attrs, [:subject, :body, :visitor_id, :profile_id]) + |> validate_required([:subject, :body]) + end +end diff --git a/lib/maker_passport/visitors/visitor.ex b/lib/maker_passport/visitors/visitor.ex new file mode 100644 index 0000000..aef745f --- /dev/null +++ b/lib/maker_passport/visitors/visitor.ex @@ -0,0 +1,33 @@ +defmodule MakerPassport.Visitors.Visitor do + use Ecto.Schema + import Ecto.Changeset + + @hash_algorithm :sha256 + @rand_size 32 + + schema "visitors" do + field :name, :string + field :email, :string + field :token, :binary + field :is_verified, :boolean, default: false + + timestamps(type: :utc_datetime) + end + + def changeset(visitor, attrs \\ %{}) do + visitor + |> cast(attrs, [:name, :email, :token, :is_verified]) + end + + def generate_token() do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), hashed_token} + end + + def decode_token(token) do + {:ok, decoded_token} = Base.url_decode64(token, padding: false) + :crypto.hash(@hash_algorithm, decoded_token) + end +end diff --git a/lib/maker_passport_web/components/core_components.ex b/lib/maker_passport_web/components/core_components.ex index 6c0fcc8..6e70443 100644 --- a/lib/maker_passport_web/components/core_components.ex +++ b/lib/maker_passport_web/components/core_components.ex @@ -72,7 +72,7 @@ defmodule MakerPassportWeb.CoreComponents do