summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorLogan Hunt <loganhunt@simponic.xyz>2022-04-06 12:13:54 -0600
committerLogan Hunt <loganhunt@simponic.xyz>2022-04-06 12:13:54 -0600
commit2055742911201258e6f755b3eb4031a1b09407f1 (patch)
treea8e0471cab55329e2e00b5d3e2011d37bb67fdb6 /lib
downloadaggiedit-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')
-rw-r--r--lib/aggiedit.ex9
-rw-r--r--lib/aggiedit/accounts.ex370
-rw-r--r--lib/aggiedit/accounts/user.ex157
-rw-r--r--lib/aggiedit/accounts/user_notifier.ex79
-rw-r--r--lib/aggiedit/accounts/user_token.ex178
-rw-r--r--lib/aggiedit/application.ex36
-rw-r--r--lib/aggiedit/mailer.ex3
-rw-r--r--lib/aggiedit/repo.ex5
-rw-r--r--lib/aggiedit/rooms.ex111
-rw-r--r--lib/aggiedit/rooms/room.ex22
-rw-r--r--lib/aggiedit/utils.ex14
-rw-r--r--lib/aggiedit_web.ex110
-rw-r--r--lib/aggiedit_web/controllers/page_controller.ex7
-rw-r--r--lib/aggiedit_web/controllers/user_auth.ex170
-rw-r--r--lib/aggiedit_web/controllers/user_confirmation_controller.ex56
-rw-r--r--lib/aggiedit_web/controllers/user_registration_controller.ex30
-rw-r--r--lib/aggiedit_web/controllers/user_reset_password_controller.ex58
-rw-r--r--lib/aggiedit_web/controllers/user_session_controller.ex27
-rw-r--r--lib/aggiedit_web/controllers/user_settings_controller.ex74
-rw-r--r--lib/aggiedit_web/endpoint.ex50
-rw-r--r--lib/aggiedit_web/gettext.ex24
-rw-r--r--lib/aggiedit_web/router.ex92
-rw-r--r--lib/aggiedit_web/telemetry.ex71
-rw-r--r--lib/aggiedit_web/templates/layout/_user_menu.html.heex10
-rw-r--r--lib/aggiedit_web/templates/layout/app.html.heex5
-rw-r--r--lib/aggiedit_web/templates/layout/live.html.heex11
-rw-r--r--lib/aggiedit_web/templates/layout/root.html.heex31
-rw-r--r--lib/aggiedit_web/templates/page/index.html.heex41
-rw-r--r--lib/aggiedit_web/templates/user_confirmation/edit.html.heex12
-rw-r--r--lib/aggiedit_web/templates/user_confirmation/new.html.heex15
-rw-r--r--lib/aggiedit_web/templates/user_registration/new.html.heex30
-rw-r--r--lib/aggiedit_web/templates/user_reset_password/edit.html.heex26
-rw-r--r--lib/aggiedit_web/templates/user_reset_password/new.html.heex15
-rw-r--r--lib/aggiedit_web/templates/user_session/new.html.heex27
-rw-r--r--lib/aggiedit_web/templates/user_settings/edit.html.heex53
-rw-r--r--lib/aggiedit_web/views/error_helpers.ex47
-rw-r--r--lib/aggiedit_web/views/error_view.ex16
-rw-r--r--lib/aggiedit_web/views/layout_view.ex7
-rw-r--r--lib/aggiedit_web/views/page_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_confirmation_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_registration_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_reset_password_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_session_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_settings_view.ex3
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 &amp; 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