diff options
author | Logan Hunt <loganhunt@simponic.xyz> | 2022-04-06 12:13:54 -0600 |
---|---|---|
committer | Logan Hunt <loganhunt@simponic.xyz> | 2022-04-06 12:13:54 -0600 |
commit | 2055742911201258e6f755b3eb4031a1b09407f1 (patch) | |
tree | a8e0471cab55329e2e00b5d3e2011d37bb67fdb6 /lib | |
download | aggiedit-2055742911201258e6f755b3eb4031a1b09407f1.tar.gz aggiedit-2055742911201258e6f755b3eb4031a1b09407f1.zip |
Initial commit; generate auth code with phx.gen.auth; added room model and association; generate room model on domain of user emails; allow users to change their email
Diffstat (limited to 'lib')
44 files changed, 2117 insertions, 0 deletions
diff --git a/lib/aggiedit.ex b/lib/aggiedit.ex new file mode 100644 index 0000000..d9da6de --- /dev/null +++ b/lib/aggiedit.ex @@ -0,0 +1,9 @@ +defmodule Aggiedit do + @moduledoc """ + Aggiedit keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/aggiedit/accounts.ex b/lib/aggiedit/accounts.ex new file mode 100644 index 0000000..9d0bd18 --- /dev/null +++ b/lib/aggiedit/accounts.ex @@ -0,0 +1,370 @@ +defmodule Aggiedit.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Aggiedit.Repo + + alias Aggiedit.Accounts.{User, UserToken, UserNotifier} + alias Aggiedit.Rooms + alias Aggiedit.Rooms.Room + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)), + {:ok, user} <- set_user_room(user) do + :ok + else + _ -> :error + end + end + + def set_user_room(user) do + # TODO: Make posts by user in previous room inaccessible by user + with domain <- Aggiedit.Utils.get_email_domain(user.email), + {:ok, room} <- Rooms.create_or_find_room_with_domain(domain) do + user + |> Repo.preload(:room) + |> User.room_changeset(room) + |> Repo.update() + else + _ -> {:error, "Could not find or create room with your email"} + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) + end + + @doc """ + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc """ + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1)) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)), + {:ok, user} <- set_user_room(user) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc """ + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end +end diff --git a/lib/aggiedit/accounts/user.ex b/lib/aggiedit/accounts/user.ex new file mode 100644 index 0000000..aeb37ed --- /dev/null +++ b/lib/aggiedit/accounts/user.ex @@ -0,0 +1,157 @@ +defmodule Aggiedit.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + alias Aggiedit.Rooms.Room + + schema "users" do + field :email, :string + field :username, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :naive_datetime + field :role, Ecto.Enum, values: [:user, :admin], default: :user + + belongs_to :room, Room, on_replace: :update + + timestamps() + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:username, :email, :password]) + |> validate_username() + |> validate_email() + |> validate_password(opts) + end + + defp validate_username(changeset) do + changeset + |> validate_required([:username]) + |> validate_format(:username, ~r/^[a-z0-9_\-]*$/, message: "only lowercase letters, numbers, underscores, and hyphens allowed") + |> unique_constraint(:username) + end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, message: "must have the @ sign, no spaces, and a domain") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, Aggiedit.Repo) + |> unique_constraint(:email) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 8, max: 72) + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs) do + user + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(user, confirmed_at: now) + end + + def room_changeset(user, room) do + change(user) + |> cast(%{:room_id => room.id}, [:room_id]) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Aggiedit.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/aggiedit/accounts/user_notifier.ex b/lib/aggiedit/accounts/user_notifier.ex new file mode 100644 index 0000000..9230716 --- /dev/null +++ b/lib/aggiedit/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Aggiedit.Accounts.UserNotifier do + import Swoosh.Email + + alias Aggiedit.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Aggiedit", "info@simponic.xyz"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password 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 deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/aggiedit/accounts/user_token.ex b/lib/aggiedit/accounts/user_token.ex new file mode 100644 index 0000000..7d9ad16 --- /dev/null +++ b/lib/aggiedit/accounts/user_token.ex @@ -0,0 +1,178 @@ +defmodule Aggiedit.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, Aggiedit.Accounts.User + + timestamps(updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %Aggiedit.Accounts.UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %Aggiedit.Accounts.UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def token_and_context_query(token, context) do + from Aggiedit.Accounts.UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def user_and_contexts_query(user, :all) do + from t in Aggiedit.Accounts.UserToken, where: t.user_id == ^user.id + end + + def user_and_contexts_query(user, [_ | _] = contexts) do + from t in Aggiedit.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/aggiedit/application.ex b/lib/aggiedit/application.ex new file mode 100644 index 0000000..497b9b8 --- /dev/null +++ b/lib/aggiedit/application.ex @@ -0,0 +1,36 @@ +defmodule Aggiedit.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the Ecto repository + Aggiedit.Repo, + # Start the Telemetry supervisor + AggieditWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: Aggiedit.PubSub}, + # Start the Endpoint (http/https) + AggieditWeb.Endpoint + # Start a worker by calling: Aggiedit.Worker.start_link(arg) + # {Aggiedit.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Aggiedit.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + AggieditWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/aggiedit/mailer.ex b/lib/aggiedit/mailer.ex new file mode 100644 index 0000000..35c9937 --- /dev/null +++ b/lib/aggiedit/mailer.ex @@ -0,0 +1,3 @@ +defmodule Aggiedit.Mailer do + use Swoosh.Mailer, otp_app: :aggiedit +end diff --git a/lib/aggiedit/repo.ex b/lib/aggiedit/repo.ex new file mode 100644 index 0000000..840c2bd --- /dev/null +++ b/lib/aggiedit/repo.ex @@ -0,0 +1,5 @@ +defmodule Aggiedit.Repo do + use Ecto.Repo, + otp_app: :aggiedit, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/aggiedit/rooms.ex b/lib/aggiedit/rooms.ex new file mode 100644 index 0000000..eac2b65 --- /dev/null +++ b/lib/aggiedit/rooms.ex @@ -0,0 +1,111 @@ +defmodule Aggiedit.Rooms do + @moduledoc """ + The Rooms context. + """ + + import Ecto.Query, warn: false + alias Aggiedit.Repo + + alias Aggiedit.Rooms.Room + + @doc """ + Returns the list of rooms. + + ## Examples + + iex> list_rooms() + [%Room{}, ...] + + """ + def list_rooms do + Repo.all(Room) + end + + @doc """ + Gets a single room. + + Raises `Ecto.NoResultsError` if the Room does not exist. + + ## Examples + + iex> get_room!(123) + %Room{} + + iex> get_room!(456) + ** (Ecto.NoResultsError) + + """ + def get_room!(id), do: Repo.get!(Room, id) + + @doc """ + Creates a room. + + ## Examples + + iex> create_room(%{field: value}) + {:ok, %Room{}} + + iex> create_room(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_room(attrs \\ %{}) do + %Room{} + |> Room.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a room. + + ## Examples + + iex> update_room(room, %{field: new_value}) + {:ok, %Room{}} + + iex> update_room(room, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_room(%Room{} = room, attrs) do + room + |> Room.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a room. + + ## Examples + + iex> delete_room(room) + {:ok, %Room{}} + + iex> delete_room(room) + {:error, %Ecto.Changeset{}} + + """ + def delete_room(%Room{} = room) do + Repo.delete(room) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking room changes. + + ## Examples + + iex> change_room(room) + %Ecto.Changeset{data: %Room{}} + + """ + def change_room(%Room{} = room, attrs \\ %{}) do + Room.changeset(room, attrs) + end + + def create_or_find_room_with_domain(domain) do + case Repo.get_by(Room, domain: domain) do + room=%Room{} -> {:ok, room} + nil -> create_room(%{domain: domain}) + end + end +end diff --git a/lib/aggiedit/rooms/room.ex b/lib/aggiedit/rooms/room.ex new file mode 100644 index 0000000..24f1b8a --- /dev/null +++ b/lib/aggiedit/rooms/room.ex @@ -0,0 +1,22 @@ +defmodule Aggiedit.Rooms.Room do + use Ecto.Schema + import Ecto.Changeset + + schema "rooms" do + field :domain, :string + + has_many :users, Aggiedit.Accounts.User + + timestamps() + end + + @doc false + def changeset(room, attrs) do + room + |> cast(attrs, [:domain]) + |> validate_required([:domain]) + |> validate_length(:domain, max: 160) + |> validate_format(:domain, ~r/^[^\s\.]+\.[^\.\s]+$/, message: "Domain cannot be a subdomain, and cannot have spaces") + |> unique_constraint(:domain) + end +end diff --git a/lib/aggiedit/utils.ex b/lib/aggiedit/utils.ex new file mode 100644 index 0000000..492b36e --- /dev/null +++ b/lib/aggiedit/utils.ex @@ -0,0 +1,14 @@ +defmodule Aggiedit.Utils do + def get_email_domain(email) do + domain_split = Regex.named_captures(~r/^.*@(?<domain>.*)$/, email)["domain"] + |> String.downcase() + |> String.split(".") + IO.puts(inspect(domain_split)) + + if Enum.count(domain_split) >= 2 do + Enum.join(Enum.take(domain_split, -2), ".") + else + nil + end + end +end
\ No newline at end of file diff --git a/lib/aggiedit_web.ex b/lib/aggiedit_web.ex new file mode 100644 index 0000000..0b7717c --- /dev/null +++ b/lib/aggiedit_web.ex @@ -0,0 +1,110 @@ +defmodule AggieditWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use AggieditWeb, :controller + use AggieditWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: AggieditWeb + + import Plug.Conn + import AggieditWeb.Gettext + alias AggieditWeb.Router.Helpers, as: Routes + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/aggiedit_web/templates", + namespace: AggieditWeb + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] + + # Include shared imports and aliases for views + unquote(view_helpers()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {AggieditWeb.LayoutView, "live.html"} + + unquote(view_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + + def component do + quote do + use Phoenix.Component + + unquote(view_helpers()) + end + end + + def router do + quote do + use Phoenix.Router + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + import AggieditWeb.Gettext + end + end + + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + import Phoenix.LiveView.Helpers + + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + + import AggieditWeb.ErrorHelpers + import AggieditWeb.Gettext + alias AggieditWeb.Router.Helpers, as: Routes + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/aggiedit_web/controllers/page_controller.ex b/lib/aggiedit_web/controllers/page_controller.ex new file mode 100644 index 0000000..97e0bf2 --- /dev/null +++ b/lib/aggiedit_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule AggieditWeb.PageController do + use AggieditWeb, :controller + + def index(conn, _params) do + render(conn, "index.html") + end +end diff --git a/lib/aggiedit_web/controllers/user_auth.ex b/lib/aggiedit_web/controllers/user_auth.ex new file mode 100644 index 0000000..02c2efe --- /dev/null +++ b/lib/aggiedit_web/controllers/user_auth.ex @@ -0,0 +1,170 @@ +defmodule AggieditWeb.UserAuth do + import Plug.Conn + import Phoenix.Controller + + alias Aggiedit.Accounts + alias AggieditWeb.Router.Helpers, as: Routes + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_aggiedit_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + user_return_to = get_session(conn, :user_return_to) + + if user.confirmed_at do + token = Accounts.generate_user_session_token(user) + + conn + |> renew_session() + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + else + conn + |> put_flash(:error, "You need to confirm your account first (please check spam).") + |> redirect(to: Routes.user_confirmation_path(conn, :new)) + end + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + AggieditWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: "/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if user_token = get_session(conn, :user_token) do + {user_token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if user_token = conn.cookies[@remember_me_cookie] do + {user_token, put_session(conn, :user_token, user_token)} + else + {nil, conn} + end + end + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: Routes.user_session_path(conn, :new)) + |> halt() + end + end + + def require_admin_user(conn, _opts) do + user = conn.assigns[:current_user] + + if !!user and user.role == :admin do + conn + else + conn + |> put_flash(:error, "You need administrator privileges.") + |> maybe_store_return_to() + |> redirect(to: Routes.user_session_path(conn, :new)) + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: "/" +end diff --git a/lib/aggiedit_web/controllers/user_confirmation_controller.ex b/lib/aggiedit_web/controllers/user_confirmation_controller.ex new file mode 100644 index 0000000..912402a --- /dev/null +++ b/lib/aggiedit_web/controllers/user_confirmation_controller.ex @@ -0,0 +1,56 @@ +defmodule AggieditWeb.UserConfirmationController do + use AggieditWeb, :controller + + alias Aggiedit.Accounts + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &Routes.user_confirmation_url(conn, :edit, &1) + ) + end + + 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." + ) + |> redirect(to: "/") + end + + def edit(conn, %{"token" => token}) do + render(conn, "edit.html", token: token) + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def update(conn, %{"token" => token}) do + case Accounts.confirm_user(token) do + {:ok, _} -> + conn + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: "/") + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case conn.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: "/") + + %{} -> + conn + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: "/") + end + end + end +end diff --git a/lib/aggiedit_web/controllers/user_registration_controller.ex b/lib/aggiedit_web/controllers/user_registration_controller.ex new file mode 100644 index 0000000..c8a4d4a --- /dev/null +++ b/lib/aggiedit_web/controllers/user_registration_controller.ex @@ -0,0 +1,30 @@ +defmodule AggieditWeb.UserRegistrationController do + use AggieditWeb, :controller + + alias Aggiedit.Accounts + alias Aggiedit.Accounts.User + alias AggieditWeb.UserAuth + + def new(conn, _params) do + changeset = Accounts.change_user_registration(%User{}) + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"user" => user_params}) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &Routes.user_confirmation_url(conn, :edit, &1) + ) + + conn + |> put_flash(:info, "User created successfully.") + |> UserAuth.log_in_user(user) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end +end diff --git a/lib/aggiedit_web/controllers/user_reset_password_controller.ex b/lib/aggiedit_web/controllers/user_reset_password_controller.ex new file mode 100644 index 0000000..ff2a9f6 --- /dev/null +++ b/lib/aggiedit_web/controllers/user_reset_password_controller.ex @@ -0,0 +1,58 @@ +defmodule AggieditWeb.UserResetPasswordController do + use AggieditWeb, :controller + + alias Aggiedit.Accounts + + plug :get_user_by_reset_password_token when action in [:edit, :update] + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &Routes.user_reset_password_url(conn, :edit, &1) + ) + end + + conn + |> put_flash( + :info, + "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)) + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def update(conn, %{"user" => user_params}) do + case Accounts.reset_user_password(conn.assigns.user, user_params) do + {:ok, _} -> + conn + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: Routes.user_session_path(conn, :new)) + + {:error, changeset} -> + render(conn, "edit.html", changeset: changeset) + end + end + + defp get_user_by_reset_password_token(conn, _opts) do + %{"token" => token} = conn.params + + if user = Accounts.get_user_by_reset_password_token(token) do + conn |> assign(:user, user) |> assign(:token, token) + else + conn + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: "/") + |> halt() + end + end +end diff --git a/lib/aggiedit_web/controllers/user_session_controller.ex b/lib/aggiedit_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..fc20cc1 --- /dev/null +++ b/lib/aggiedit_web/controllers/user_session_controller.ex @@ -0,0 +1,27 @@ +defmodule AggieditWeb.UserSessionController do + use AggieditWeb, :controller + + alias Aggiedit.Accounts + alias AggieditWeb.UserAuth + + def new(conn, _params) do + render(conn, "new.html", error_message: nil) + end + + def create(conn, %{"user" => user_params}) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + UserAuth.log_in_user(conn, user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + render(conn, "new.html", error_message: "Invalid email or password") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/aggiedit_web/controllers/user_settings_controller.ex b/lib/aggiedit_web/controllers/user_settings_controller.ex new file mode 100644 index 0000000..0f83a96 --- /dev/null +++ b/lib/aggiedit_web/controllers/user_settings_controller.ex @@ -0,0 +1,74 @@ +defmodule AggieditWeb.UserSettingsController do + use AggieditWeb, :controller + + alias Aggiedit.Accounts + alias AggieditWeb.UserAuth + + plug :assign_email_and_password_changesets + + def edit(conn, _params) do + render(conn, "edit.html") + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"current_password" => password, "user" => user_params} = params + user = conn.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_update_email_instructions( + applied_user, + user.email, + &Routes.user_settings_url(conn, :confirm_email, &1) + ) + + conn + |> put_flash( + :info, + "A link to confirm your email change has been sent to the new address." + ) + |> redirect(to: Routes.user_settings_path(conn, :edit)) + + {:error, changeset} -> + render(conn, "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 + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) + |> UserAuth.log_in_user(user) + + {:error, changeset} -> + render(conn, "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 + :ok -> + conn + |> put_flash(:info, "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.") + |> redirect(to: Routes.user_settings_path(conn, :edit)) + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + user = conn.assigns.current_user + + conn + |> assign(:email_changeset, Accounts.change_user_email(user)) + |> assign(:password_changeset, Accounts.change_user_password(user)) + end +end diff --git a/lib/aggiedit_web/endpoint.ex b/lib/aggiedit_web/endpoint.ex new file mode 100644 index 0000000..47209ad --- /dev/null +++ b/lib/aggiedit_web/endpoint.ex @@ -0,0 +1,50 @@ +defmodule AggieditWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :aggiedit + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_aggiedit_key", + signing_salt: "yXlQsIK6" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :aggiedit, + gzip: false, + only: ~w(assets fonts images favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :aggiedit + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug AggieditWeb.Router +end diff --git a/lib/aggiedit_web/gettext.ex b/lib/aggiedit_web/gettext.ex new file mode 100644 index 0000000..7cb0bef --- /dev/null +++ b/lib/aggiedit_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule AggieditWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import AggieditWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :aggiedit +end diff --git a/lib/aggiedit_web/router.ex b/lib/aggiedit_web/router.ex new file mode 100644 index 0000000..e400391 --- /dev/null +++ b/lib/aggiedit_web/router.ex @@ -0,0 +1,92 @@ +defmodule AggieditWeb.Router do + use AggieditWeb, :router + + import AggieditWeb.UserAuth + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {AggieditWeb.LayoutView, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + plug :fetch_current_user + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", AggieditWeb do + pipe_through :browser + + get "/", PageController, :index + end + + # Other scopes may use custom stacks. + # scope "/api", AggieditWeb do + # pipe_through :api + # end + + # Enables LiveDashboard only for development + # + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + if Mix.env() in [:dev, :test] do + import Phoenix.LiveDashboard.Router + + scope "/" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: AggieditWeb.Telemetry + end + end + + # Enables the Swoosh mailbox preview in development. + # + # Note that preview only shows emails that were sent by the same + # node running the Phoenix server. + if Mix.env() == :dev do + scope "/dev" do + pipe_through :browser + + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end + + ## Authentication routes + + scope "/", AggieditWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + get "/users/register", UserRegistrationController, :new + post "/users/register", UserRegistrationController, :create + get "/users/log_in", UserSessionController, :new + post "/users/log_in", UserSessionController, :create + get "/users/reset_password", UserResetPasswordController, :new + post "/users/reset_password", UserResetPasswordController, :create + get "/users/reset_password/:token", UserResetPasswordController, :edit + put "/users/reset_password/:token", UserResetPasswordController, :update + end + + scope "/", AggieditWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/users/settings", UserSettingsController, :edit + put "/users/settings", UserSettingsController, :update + get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email + end + + scope "/", AggieditWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + get "/users/confirm", UserConfirmationController, :new + post "/users/confirm", UserConfirmationController, :create + get "/users/confirm/:token", UserConfirmationController, :edit + post "/users/confirm/:token", UserConfirmationController, :update + end +end diff --git a/lib/aggiedit_web/telemetry.ex b/lib/aggiedit_web/telemetry.ex new file mode 100644 index 0000000..358bf80 --- /dev/null +++ b/lib/aggiedit_web/telemetry.ex @@ -0,0 +1,71 @@ +defmodule AggieditWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("aggiedit.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("aggiedit.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("aggiedit.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("aggiedit.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("aggiedit.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {AggieditWeb, :count_users, []} + ] + end +end diff --git a/lib/aggiedit_web/templates/layout/_user_menu.html.heex b/lib/aggiedit_web/templates/layout/_user_menu.html.heex new file mode 100644 index 0000000..f281cfc --- /dev/null +++ b/lib/aggiedit_web/templates/layout/_user_menu.html.heex @@ -0,0 +1,10 @@ +<ul> +<%= if @current_user do %> + <li><%= @current_user.email %></li> + <li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li> + <li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li> +<% else %> + <li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li> + <li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li> +<% end %> +</ul> diff --git a/lib/aggiedit_web/templates/layout/app.html.heex b/lib/aggiedit_web/templates/layout/app.html.heex new file mode 100644 index 0000000..169aed9 --- /dev/null +++ b/lib/aggiedit_web/templates/layout/app.html.heex @@ -0,0 +1,5 @@ +<main class="container"> + <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> + <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> + <%= @inner_content %> +</main> diff --git a/lib/aggiedit_web/templates/layout/live.html.heex b/lib/aggiedit_web/templates/layout/live.html.heex new file mode 100644 index 0000000..a29d604 --- /dev/null +++ b/lib/aggiedit_web/templates/layout/live.html.heex @@ -0,0 +1,11 @@ +<main class="container"> + <p class="alert alert-info" role="alert" + phx-click="lv:clear-flash" + phx-value-key="info"><%= live_flash(@flash, :info) %></p> + + <p class="alert alert-danger" role="alert" + phx-click="lv:clear-flash" + phx-value-key="error"><%= live_flash(@flash, :error) %></p> + + <%= @inner_content %> +</main> diff --git a/lib/aggiedit_web/templates/layout/root.html.heex b/lib/aggiedit_web/templates/layout/root.html.heex new file mode 100644 index 0000000..f653fd8 --- /dev/null +++ b/lib/aggiedit_web/templates/layout/root.html.heex @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"/> + <meta http-equiv="X-UA-Compatible" content="IE=edge"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <%= csrf_meta_tag() %> + <%= live_title_tag assigns[:page_title] || "Aggiedit", suffix: " ยท Phoenix Framework" %> + <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/> + <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script> + </head> + <body> + <header> + <section class="container"> + <nav> + <ul> + <li><a href="https://hexdocs.pm/phoenix/overview.html">Get Started</a></li> + <%= if function_exported?(Routes, :live_dashboard_path, 2) do %> + <li><%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home) %></li> + <% end %> + </ul> + <%= render "_user_menu.html", assigns %> + </nav> + <a href="https://phoenixframework.org/" class="phx-logo"> + <img src={Routes.static_path(@conn, "/images/phoenix.png")} alt="Phoenix Framework Logo"/> + </a> + </section> + </header> + <%= @inner_content %> + </body> +</html> diff --git a/lib/aggiedit_web/templates/page/index.html.heex b/lib/aggiedit_web/templates/page/index.html.heex new file mode 100644 index 0000000..f844bd8 --- /dev/null +++ b/lib/aggiedit_web/templates/page/index.html.heex @@ -0,0 +1,41 @@ +<section class="phx-hero"> + <h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1> + <p>Peace of mind from prototype to production</p> +</section> + +<section class="row"> + <article class="column"> + <h2>Resources</h2> + <ul> + <li> + <a href="https://hexdocs.pm/phoenix/overview.html">Guides & Docs</a> + </li> + <li> + <a href="https://github.com/phoenixframework/phoenix">Source</a> + </li> + <li> + <a href="https://github.com/phoenixframework/phoenix/blob/v1.6/CHANGELOG.md">v1.6 Changelog</a> + </li> + </ul> + </article> + <article class="column"> + <h2>Help</h2> + <ul> + <li> + <a href="https://elixirforum.com/c/phoenix-forum">Forum</a> + </li> + <li> + <a href="https://web.libera.chat/#elixir">#elixir on Libera Chat (IRC)</a> + </li> + <li> + <a href="https://twitter.com/elixirphoenix">Twitter @elixirphoenix</a> + </li> + <li> + <a href="https://elixir-slackin.herokuapp.com/">Elixir on Slack</a> + </li> + <li> + <a href="https://discord.gg/elixir">Elixir on Discord</a> + </li> + </ul> + </article> +</section> diff --git a/lib/aggiedit_web/templates/user_confirmation/edit.html.heex b/lib/aggiedit_web/templates/user_confirmation/edit.html.heex new file mode 100644 index 0000000..e9bf443 --- /dev/null +++ b/lib/aggiedit_web/templates/user_confirmation/edit.html.heex @@ -0,0 +1,12 @@ +<h1>Confirm account</h1> + +<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}> + <div> + <%= submit "Confirm my account" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +</p> diff --git a/lib/aggiedit_web/templates/user_confirmation/new.html.heex b/lib/aggiedit_web/templates/user_confirmation/new.html.heex new file mode 100644 index 0000000..4d9bee3 --- /dev/null +++ b/lib/aggiedit_web/templates/user_confirmation/new.html.heex @@ -0,0 +1,15 @@ +<h1>Resend confirmation instructions</h1> + +<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}> + <%= label f, :email %> + <%= email_input f, :email, required: true %> + + <div> + <%= submit "Resend confirmation instructions" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +</p> diff --git a/lib/aggiedit_web/templates/user_registration/new.html.heex b/lib/aggiedit_web/templates/user_registration/new.html.heex new file mode 100644 index 0000000..6ff00fc --- /dev/null +++ b/lib/aggiedit_web/templates/user_registration/new.html.heex @@ -0,0 +1,30 @@ +<h1>Register</h1> + +<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}> + <%= if @changeset.action do %> + <div class="alert alert-danger"> + <p>Oops, something went wrong! Please check the errors below.</p> + </div> + <% end %> + + <%= label f, :email %> + <%= email_input f, :email, required: true %> + <%= error_tag f, :email %> + + <%= label f, :username %> + <%= text_input f, :username, required: true %> + <%= error_tag f, :username %> + + <%= label f, :password %> + <%= password_input f, :password, required: true %> + <%= error_tag f, :password %> + + <div> + <%= submit "Register" %> + </div> +</.form> + +<p> + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/aggiedit_web/templates/user_reset_password/edit.html.heex b/lib/aggiedit_web/templates/user_reset_password/edit.html.heex new file mode 100644 index 0000000..d8efb4b --- /dev/null +++ b/lib/aggiedit_web/templates/user_reset_password/edit.html.heex @@ -0,0 +1,26 @@ +<h1>Reset password</h1> + +<.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}> + <%= if @changeset.action do %> + <div class="alert alert-danger"> + <p>Oops, something went wrong! Please check the errors below.</p> + </div> + <% end %> + + <%= label f, :password, "New password" %> + <%= password_input f, :password, required: true %> + <%= error_tag f, :password %> + + <%= label f, :password_confirmation, "Confirm new password" %> + <%= password_input f, :password_confirmation, required: true %> + <%= error_tag f, :password_confirmation %> + + <div> + <%= submit "Reset password" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +</p> diff --git a/lib/aggiedit_web/templates/user_reset_password/new.html.heex b/lib/aggiedit_web/templates/user_reset_password/new.html.heex new file mode 100644 index 0000000..126cdba --- /dev/null +++ b/lib/aggiedit_web/templates/user_reset_password/new.html.heex @@ -0,0 +1,15 @@ +<h1>Forgot your password?</h1> + +<.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}> + <%= label f, :email %> + <%= email_input f, :email, required: true %> + + <div> + <%= submit "Send instructions to reset password" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> +</p> diff --git a/lib/aggiedit_web/templates/user_session/new.html.heex b/lib/aggiedit_web/templates/user_session/new.html.heex new file mode 100644 index 0000000..49a7d79 --- /dev/null +++ b/lib/aggiedit_web/templates/user_session/new.html.heex @@ -0,0 +1,27 @@ +<h1>Log in</h1> + +<.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}> + <%= if @error_message do %> + <div class="alert alert-danger"> + <p><%= @error_message %></p> + </div> + <% end %> + + <%= label f, :email %> + <%= email_input f, :email, required: true %> + + <%= label f, :password %> + <%= password_input f, :password, required: true %> + + <%= label f, :remember_me, "Keep me logged in for 60 days" %> + <%= checkbox f, :remember_me %> + + <div> + <%= submit "Log in" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/aggiedit_web/templates/user_settings/edit.html.heex b/lib/aggiedit_web/templates/user_settings/edit.html.heex new file mode 100644 index 0000000..9863bc5 --- /dev/null +++ b/lib/aggiedit_web/templates/user_settings/edit.html.heex @@ -0,0 +1,53 @@ +<h1>Settings</h1> + +<h3>Change email</h3> + +<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email"> + <%= if @email_changeset.action do %> + <div class="alert alert-danger"> + <p>Oops, something went wrong! Please check the errors below.</p> + </div> + <% end %> + + <%= hidden_input f, :action, name: "action", value: "update_email" %> + + <%= label f, :email %> + <%= email_input f, :email, required: true %> + <%= error_tag f, :email %> + + <%= label f, :current_password, for: "current_password_for_email" %> + <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %> + <%= error_tag f, :current_password %> + + <div> + <%= submit "Change email" %> + </div> +</.form> + +<h3>Change password</h3> + +<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password"> + <%= if @password_changeset.action do %> + <div class="alert alert-danger"> + <p>Oops, something went wrong! Please check the errors below.</p> + </div> + <% end %> + + <%= hidden_input f, :action, name: "action", value: "update_password" %> + + <%= label f, :password, "New password" %> + <%= password_input f, :password, required: true %> + <%= error_tag f, :password %> + + <%= label f, :password_confirmation, "Confirm new password" %> + <%= password_input f, :password_confirmation, required: true %> + <%= error_tag f, :password_confirmation %> + + <%= label f, :current_password, for: "current_password_for_password" %> + <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %> + <%= error_tag f, :current_password %> + + <div> + <%= submit "Change password" %> + </div> +</.form> diff --git a/lib/aggiedit_web/views/error_helpers.ex b/lib/aggiedit_web/views/error_helpers.ex new file mode 100644 index 0000000..651e48f --- /dev/null +++ b/lib/aggiedit_web/views/error_helpers.ex @@ -0,0 +1,47 @@ +defmodule AggieditWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + use Phoenix.HTML + + @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) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate "is invalid" in the "errors" domain + # dgettext("errors", "is invalid") + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + # This requires us to call the Gettext module passing our gettext + # backend as first argument. + # + # Note we use the "errors" domain, which means translations + # should be written to the errors.po file. The :count option is + # set by Ecto and indicates we should also apply plural rules. + if count = opts[:count] do + Gettext.dngettext(AggieditWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(AggieditWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/lib/aggiedit_web/views/error_view.ex b/lib/aggiedit_web/views/error_view.ex new file mode 100644 index 0000000..3615b3b --- /dev/null +++ b/lib/aggiedit_web/views/error_view.ex @@ -0,0 +1,16 @@ +defmodule AggieditWeb.ErrorView do + use AggieditWeb, :view + + # 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 + + # 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) + end +end diff --git a/lib/aggiedit_web/views/layout_view.ex b/lib/aggiedit_web/views/layout_view.ex new file mode 100644 index 0000000..8f4b14f --- /dev/null +++ b/lib/aggiedit_web/views/layout_view.ex @@ -0,0 +1,7 @@ +defmodule AggieditWeb.LayoutView do + use AggieditWeb, :view + + # Phoenix LiveDashboard is available only in development by default, + # so we instruct Elixir to not warn if the dashboard route is missing. + @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} +end diff --git a/lib/aggiedit_web/views/page_view.ex b/lib/aggiedit_web/views/page_view.ex new file mode 100644 index 0000000..f7e6157 --- /dev/null +++ b/lib/aggiedit_web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule AggieditWeb.PageView do + use AggieditWeb, :view +end diff --git a/lib/aggiedit_web/views/user_confirmation_view.ex b/lib/aggiedit_web/views/user_confirmation_view.ex new file mode 100644 index 0000000..aa856e4 --- /dev/null +++ b/lib/aggiedit_web/views/user_confirmation_view.ex @@ -0,0 +1,3 @@ +defmodule AggieditWeb.UserConfirmationView do + use AggieditWeb, :view +end diff --git a/lib/aggiedit_web/views/user_registration_view.ex b/lib/aggiedit_web/views/user_registration_view.ex new file mode 100644 index 0000000..cf3d53d --- /dev/null +++ b/lib/aggiedit_web/views/user_registration_view.ex @@ -0,0 +1,3 @@ +defmodule AggieditWeb.UserRegistrationView do + use AggieditWeb, :view +end diff --git a/lib/aggiedit_web/views/user_reset_password_view.ex b/lib/aggiedit_web/views/user_reset_password_view.ex new file mode 100644 index 0000000..40d6f4c --- /dev/null +++ b/lib/aggiedit_web/views/user_reset_password_view.ex @@ -0,0 +1,3 @@ +defmodule AggieditWeb.UserResetPasswordView do + use AggieditWeb, :view +end diff --git a/lib/aggiedit_web/views/user_session_view.ex b/lib/aggiedit_web/views/user_session_view.ex new file mode 100644 index 0000000..b87ce6b --- /dev/null +++ b/lib/aggiedit_web/views/user_session_view.ex @@ -0,0 +1,3 @@ +defmodule AggieditWeb.UserSessionView do + use AggieditWeb, :view +end diff --git a/lib/aggiedit_web/views/user_settings_view.ex b/lib/aggiedit_web/views/user_settings_view.ex new file mode 100644 index 0000000..8257d7e --- /dev/null +++ b/lib/aggiedit_web/views/user_settings_view.ex @@ -0,0 +1,3 @@ +defmodule AggieditWeb.UserSettingsView do + use AggieditWeb, :view +end |