diff options
Diffstat (limited to 'lib/aggiedit_web')
32 files changed, 1023 insertions, 0 deletions
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 |