diff --git a/.gitignore b/.gitignore index 992ce9ff778bef3c3bedf5ef5636100a86279809..41b1bd8b624263ad70d1172bbb7f8db979b69525 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ npm-debug.log # Local config env variables .env -.idea +# Intellij generated code +/.idea/ +volt.iml diff --git a/config/config.exs b/config/config.exs index 6a063bb550c9175f9c4f6366924ce6664a9e22ff..19040e055e23bec5c376a02909e3e13f2efbd23c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -13,6 +13,7 @@ config :volt, # Configures the endpoint config :volt, VoltWeb.Endpoint, url: [host: "localhost"], + http: [port: 4000, timeout: 15], render_errors: [view: VoltWeb.ErrorView, accepts: ~w(html json), layout: false], pubsub_server: Volt.PubSub, live_view: [signing_salt: "1oLL9rdz"] diff --git a/config/test.exs b/config/test.exs index 61c7f77107fce73a078f692af3ec6ea9e45fa7cb..910fedd6fce32665187b3c4a9bd62ff0b7b93129 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :pbkdf2_elixir, :rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/env.template b/env.template index 607d06ed1c3a30712a8aa0ec35b6fd23826f6371..78aafd2478a29450a8d5a3b11f70fca776a50691 100644 --- a/env.template +++ b/env.template @@ -5,3 +5,4 @@ # export POSTGRES_USER_TEST="REPLACE_ME" # export POSTGRES_PASSWORD_TEST="REPLACE_ME" # export POSTGRES_HOST_TEST="REPLACE_ME" +# export BING_MAP_KEY="REPLACE_ME" \ No newline at end of file diff --git a/features/config.exs b/features/config.exs index 87d61ea395e82845327122b0087fa19d7a810489..32b4c11476069ef5e55fdbd8c1980f61d77115bd 100644 --- a/features/config.exs +++ b/features/config.exs @@ -1,7 +1,15 @@ defmodule WhiteBreadConfig do use WhiteBread.SuiteConfiguration - suite name: "All", - context: WhiteBreadContext, - feature_paths: ["features/"] + suite name: "Courier", + context: WhiteBreadContext.Courier, + feature_paths: ["features/contexts/courier/*.feature"] + + suite name: "Customer", + context: WhiteBreadContext.Customer, + feature_paths: ["features/contexts/customer/*.feature"] + + suite name: "Restaurant", + context: WhiteBreadContext.Restaurant, + feature_paths: ["features/contexts/restaurant/*.feature"] end diff --git a/features/contexts/courier/courier_context.exs b/features/contexts/courier/courier_context.exs new file mode 100644 index 0000000000000000000000000000000000000000..6fc71e3f5b3e0b9d0f34f3d6ab3736217a0add65 --- /dev/null +++ b/features/contexts/courier/courier_context.exs @@ -0,0 +1,87 @@ +defmodule WhiteBreadContext.Courier do + use WhiteBread.Context + use Hound.Helpers + alias Volt.{Repo, Accounts.Restaurant, Accounts.Customer, Accounts.Courier} + + feature_starting_state fn -> + Application.ensure_all_started(:hound) + %{} + end + scenario_starting_state fn _state -> + Hound.start_session + Ecto.Adapters.SQL.Sandbox.checkout(Volt.Repo) + Ecto.Adapters.SQL.Sandbox.mode(Volt.Repo, {:shared, self()}) + + %{} + end + scenario_finalize fn _status, _state -> + Hound.end_session + Ecto.Adapters.SQL.Sandbox.checkin(Volt.Repo) + end + + given_ ~r/^that no user is logged in$/, fn state -> + {:ok, state} + end + + and_ ~r/^my email is "(?<email>[^"]+)" $/, + fn state, %{email: email} -> + {:ok, state |> Map.put(:email, email)} + end + + and_ ~r/^my password is "(?<password>[^"]+)"$/, + fn state, %{password: password} -> + {:ok, state |> Map.put(:password, password)} + end + + and_ ~r/^my name is "(?<first_name>[^"]+)", "(?<last_name>[^"]+)"$/, + fn state, %{first_name: first_name, last_name: last_name} -> + {:ok, state |> Map.put(:first_name, first_name) |> Map.put(:last_name, last_name)} + end + + and_ ~r/^my phone number is "(?<phone_number>[^"]+)"$/, + fn state, %{phone_number: phone_number} -> + {:ok, state |> Map.put(:phone_number, phone_number)} + end + + and_ ~r/^my status is "(?<courier_status>[^"]+)"$/, + fn state, %{courier_status: courier_status} -> + {:ok, state |> Map.put(:courier_status, courier_status)} + end + + and_ ~r/^I open the welcome page$/, fn state -> + navigate_to "/" + {:ok, state} + end + + and_ ~r/^I click the register as a courier button$/, fn state -> + click({:id, "courier_register"}) + {:ok, state} + end + + and_ ~r/^I enter my information$/, fn state -> + fill_field({:id, "email"}, state[:email]) + fill_field({:id, "password"}, state[:password]) + fill_field({:id, "first_name"}, state[:first_name]) + fill_field({:id, "last_name"}, state[:last_name]) + fill_field({:id, "phone_number"}, state[:phone_number]) + fill_field({:id, "courier_status"}, state[:courier_status]) + {:ok, state} + end + + when_ ~r/^I summit the register button$/, fn state -> + click({:id, "submit_button"}) + {:ok, state} + end + + then_ ~r/^I should receive a confirmation message$/, fn state -> + assert visible_in_page? ~r/Customer created successfully./ + {:ok, state} + end + + and_ ~r/^the following courier already exist$/, fn state, %{table_data: table} -> + table + |> Enum.map(fn courier -> Courier.changeset(%Courier{}, courier) end) + |> Enum.each(fn changeset -> Repo.insert!(changeset) end) + {:ok, state} + end +end diff --git a/features/courier_registration.feature b/features/contexts/courier/courier_registration.feature similarity index 100% rename from features/courier_registration.feature rename to features/contexts/courier/courier_registration.feature diff --git a/features/contexts/white_bread_context.exs b/features/contexts/customer/customer_context.exs similarity index 50% rename from features/contexts/white_bread_context.exs rename to features/contexts/customer/customer_context.exs index e3074f6300c1e3f41d9a322f44efe4e0ece768cc..a4fe26d069858b41a3593e96c1106051747f5e6b 100644 --- a/features/contexts/white_bread_context.exs +++ b/features/contexts/customer/customer_context.exs @@ -1,6 +1,7 @@ -defmodule WhiteBreadContext do +defmodule WhiteBreadContext.Customer do use WhiteBread.Context use Hound.Helpers + import Volt.AccountsFixtures alias Volt.{Repo, Accounts.Restaurant, Accounts.Customer, Accounts.Courier} feature_starting_state fn -> @@ -19,37 +20,6 @@ defmodule WhiteBreadContext do Ecto.Adapters.SQL.Sandbox.checkin(Volt.Repo) end - ## -- RESTAURANT -- ## - - and_ ~r/^I open restaurant registration page$/, fn state -> - navigate_to "/restaurant/new" - {:ok, state} - end - - and_ ~r/^I enter the restaurant information$/, fn state -> - fill_field({:id, "restaurant_email"}, "giorgio_cuisine@restaurant.gov") - fill_field({:id, "restaurant_password"}, "qwerty123") - fill_field({:id, "restaurant_first_name"}, "Giorgio") - fill_field({:id, "restaurant_last_name"}, "Shumaylovi") - fill_field({:id, "restaurant_phone_number"}, "+37253584669") - fill_field({:id, "restaurant_name"}, "Shit's Bussin") - fill_field({:id, "restaurant_address"}, "Joe Mama 69") - fill_field({:id, "restaurant_city"}, "Tartu") - fill_field({:id, "restaurant_zip_code"}, "51004") - fill_field({:id, "restaurant_price_level"}, 1) - {:ok, state} - end - - when_ ~r/^I submit the form$/, fn state -> - submit_element({:id, "submit"}) - {:ok, state} - end - - then_ ~r/^I should be redirected to the restaurant dashboard$/, fn state -> - {:ok, state} - end - - ## -- CUSTOMER -- ## given_ ~r/^that no user is logged in$/, fn state -> {:ok, state} end @@ -125,70 +95,19 @@ defmodule WhiteBreadContext do and_ ~r/^the following customer already exist$/, fn state, %{table_data: table} -> table - |> Enum.map(fn customer -> Customer.changeset(%Customer{}, customer) end) - |> Enum.each(fn changeset -> Repo.insert!(changeset) end) - {:ok, state} - end - - ## -- COURIER -- ## - given_ ~r/^that no user is logged in$/, fn state -> - {:ok, state} - end - - and_ ~r/^my email is "(?<email>[^"]+)" and password is "(?<password>[^"]+)"$/, - fn state, %{email: email, password: password} -> - {:ok, state |> Map.put(:email, email) |> Map.put(:password, password)} - end - - and_ ~r/^my name is "(?<first_name>[^"]+)", "(?<last_name>[^"]+)"$/, - fn state, %{first_name: first_name, last_name: last_name} -> - {:ok, state |> Map.put(:first_name, first_name) |> Map.put(:last_name, last_name)} - end - - and_ ~r/^my phone number is "(?<phone_number>[^"]+)"$/, - fn state, %{phone_number: phone_number} -> - {:ok, state |> Map.put(:phone_number, phone_number)} - end - - and_ ~r/^my courier_status is "(?<courier_status>[^"]+)"$/, - fn state, %{courier_status: courier_status} -> - {:ok, state |> Map.put(:courier_status, courier_status)} - end - - and_ ~r/^I open the welcome page$/, fn state -> - navigate_to "/" + |> Enum.map(fn customer -> customer_fixture(customer) end) {:ok, state} end - and_ ~r/^I click the register as courier button$/, fn state -> - click({:id, "courier_register"}) - {:ok, state} - end + # use current location/Map - and_ ~r/^I enter my information$/, fn state -> - fill_field({:id, "email"}, state[:email]) - fill_field({:id, "password"}, state[:password]) - fill_field({:id, "first_name"}, state[:first_name]) - fill_field({:id, "last_name"}, state[:last_name]) - fill_field({:id, "phone_number"}, state[:phone_number]) - ill_field({:id, "courier_status"}, state[:courier_status]) - {:ok, state} - end - - when_ ~r/^I summit the register button$/, fn state -> - click({:id, "submit_button"}) + when_ ~r/^I click on `use current location` checkbox$/, fn state -> + click({:id, "use_current_location_check"}) {:ok, state} end - then_ ~r/^I should receive a confirmation message$/, fn state -> - assert visible_in_page? ~r/Customer created successfully./ - {:ok, state} - end - - and_ ~r/^the following courier already exist$/, fn state, %{table_data: table} -> - table - |> Enum.map(fn courier -> Courier.changeset(%Courier{}, courier) end) - |> Enum.each(fn changeset -> Repo.insert!(changeset) end) + then_ ~r/^Address, city and postal code fields are filled$/, fn state -> + assert inner_text({:id, "city"}) != nil {:ok, state} end end diff --git a/features/customer_register.feature b/features/contexts/customer/customer_registration.feature similarity index 78% rename from features/customer_register.feature rename to features/contexts/customer/customer_registration.feature index 89159500b93d5a23beb274fbbf173dba6034f006..e7e7d344303cf03ad076b71636f21e446f6e14b0 100644 --- a/features/customer_register.feature +++ b/features/contexts/customer/customer_registration.feature @@ -1,6 +1,6 @@ -Feature: Customer Register - As a Customer - I want to be able to self-register +Feature: Customer Registration + As a Customer + I want to be able to self-register so that I can be able to order food from Volt. Scenario: Customer register (with confirmation) @@ -16,12 +16,12 @@ Feature: Customer Register And I enter my information When I summit the register button Then I should receive a confirmation message - + Scenario: Customer register (rejection with invalid field) Given that no user is logged in - And my email is "wrong email" and password is "my111good*password" + And my email is "email@example.com" and password is "my111good*password" And my name is "Romain", "BARRERE" - And my phone number is "+33641941064" + And my phone number is "zessfzef'" And my address is "Raatuse 22", "51009", "Tartu" And my birth date is "1999-03-12" And my bank card is "wrong card" @@ -30,7 +30,7 @@ Feature: Customer Register And I enter my information When I summit the register button Then I should receive a rejection message - + Scenario: Customer register (rejection with already taken information) Given that no user is logged in And the following customer already exist @@ -47,3 +47,10 @@ Feature: Customer Register And I enter my information When I summit the register button Then I should receive a rejection message + + Scenario: Customer register when `use current location` checkbox is checked, then address, city and postal code are filled + Given that no user is logged in + And I open the welcome page + And I click the register as customer button + When I click on `use current location` checkbox + Then Address, city and postal code fields are filled diff --git a/features/contexts/restaurant/restaurant_add_item.feature b/features/contexts/restaurant/restaurant_add_item.feature new file mode 100644 index 0000000000000000000000000000000000000000..6a772082348a5b6a0aca9afa9c443d9c210aeaaa --- /dev/null +++ b/features/contexts/restaurant/restaurant_add_item.feature @@ -0,0 +1,16 @@ +Feature: Restaurant Add Item + As a restaurant worker + I want to be able to add items to the menu + so that potential customers can see the items we have for offer. + + + Scenario: Restaurant add item (with confirmation) + Given that I want to add this item: "Pizza", "Cheese", "10.99" + And I open restaurant registration page + And I enter the restaurant information + When I submit the form + Then I open the page to add an item + And I fill the form + When I submit the form + Then I should have a confirmation that item has been created + \ No newline at end of file diff --git a/features/contexts/restaurant/restaurant_context.exs b/features/contexts/restaurant/restaurant_context.exs new file mode 100644 index 0000000000000000000000000000000000000000..cccc08a9750d590204ff18d914b4436ad453fdf2 --- /dev/null +++ b/features/contexts/restaurant/restaurant_context.exs @@ -0,0 +1,78 @@ +defmodule WhiteBreadContext.Restaurant do + use WhiteBread.Context + use Hound.Helpers + import Volt.AccountsFixtures + alias Volt.{Repo, Accounts.Restaurant, Accounts.Customer, Accounts.Courier} + import VoltWeb.RestaurantAuth + + feature_starting_state fn -> + Application.ensure_all_started(:hound) + %{} + end + scenario_starting_state fn _state -> + Hound.start_session + Ecto.Adapters.SQL.Sandbox.checkout(Volt.Repo) + Ecto.Adapters.SQL.Sandbox.mode(Volt.Repo, {:shared, self()}) + + %{} + end + scenario_finalize fn _status, _state -> + Hound.end_session + Ecto.Adapters.SQL.Sandbox.checkin(Volt.Repo) + end + + and_ ~r/^I open restaurant registration page$/, fn state -> + navigate_to "/restaurants/register" + {:ok, state} + end + + and_ ~r/^I enter the restaurant information$/, fn state -> + fill_field({:id, "restaurant_email"}, "giorgio_cuisine@restaurant.gov") + fill_field({:id, "restaurant_password"}, "qwerty123uyghyiu") + fill_field({:id, "restaurant_first_name"}, "Giorgio") + fill_field({:id, "restaurant_last_name"}, "Shumaylovi") + fill_field({:id, "restaurant_phone_number"}, "+37253584669") + fill_field({:id, "restaurant_name"}, "Shit's Bussin") + fill_field({:id, "restaurant_address"}, "Joe Mama 69") + fill_field({:id, "restaurant_city"}, "Tartu") + fill_field({:id, "restaurant_zip_code"}, "51004") + fill_field({:id, "restaurant_price_level"}, 1) + {:ok, state} + end + + when_ ~r/^I submit the form$/, fn state -> + submit_element({:id, "submit"}) + {:ok, state} + end + + then_ ~r/^I should be redirected to the restaurant dashboard$/, fn state -> + {:ok, state} + end + + given_ ~r/^that a restaurant worker is connected$/, fn state -> + log_in_restaurant(state, restaurant_fixture()) + {:ok, state} + end + + given_ ~r/^that I want to add this item: "(?<name>[^"]+)", "(?<description>[^"]+)", "(?<unit_price>[^"]+)"$/, + fn state, %{name: name, description: description, unit_price: unit_price} -> + {:ok, state |> Map.put(:name, name) |> Map.put(:description, description) |> Map.put(:unit_price, unit_price)} + end + + then_ ~r/^I open the page to add an item$/, fn state -> + navigate_to "/restaurants/additem" + {:ok, state} + end + + and_ ~r/^I fill the form$/, fn state -> + fill_field({:id, "item_name"}, state[:name]) + fill_field({:id, "item_description"}, state[:description]) + fill_field({:id, "item_unit_price"}, state[:unit_price]) + {:ok, state} + end + + then_ ~r/^I should have a confirmation that item has been created$/, fn state -> + assert visible_in_page? ~r/Item created successfully./ + {:ok, state} + end +end diff --git a/features/restaurant/restaurant_registration.feature b/features/contexts/restaurant/restaurant_registration.feature similarity index 100% rename from features/restaurant/restaurant_registration.feature rename to features/contexts/restaurant/restaurant_registration.feature diff --git a/lib/volt/accounts.ex b/lib/volt/accounts.ex index 575ec7337e3c3eb01c189045758663a9850dace3..e789836c71c91b42ad17886871dbbfe4dbb41bc4 100644 --- a/lib/volt/accounts.ex +++ b/lib/volt/accounts.ex @@ -21,276 +21,1284 @@ defmodule Volt.Accounts do Repo.all(Restaurant) end + alias Volt.Accounts.Courier + @doc """ - Gets a single restaurant. + Returns the list of couriers. - Raises `Ecto.NoResultsError` if the Restaurant does not exist. + ## Examples + + iex> list_couriers() + [%Courier{}, ...] + + """ + def list_couriers do + Repo.all(Courier) + end + + @doc """ + Gets a single courier. + + Raises `Ecto.NoResultsError` if the Courier does not exist. ## Examples - iex> get_restaurant!(123) - %Restaurant{} + iex> get_courier!(123) + %Courier{} - iex> get_restaurant!(456) + iex> get_courier!(456) ** (Ecto.NoResultsError) """ - def get_restaurant!(id), do: Repo.get!(Restaurant, id) + def get_courier!(id), do: Repo.get!(Courier, id) + + alias Volt.Accounts.Customer @doc """ - Creates a restaurant. + Returns the list of customers. ## Examples - iex> create_restaurant(%{field: value}) - {:ok, %Restaurant{}} + iex> list_customers() + [%Customer{}, ...] - iex> create_restaurant(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + def list_customers do + Repo.all(Customer) + end + + alias Volt.Accounts.{Courier, CourierToken, CourierNotifier} + + ## Database getters + + @doc """ + Gets a courier by email. + + ## Examples + + iex> get_courier_by_email("foo@example.com") + %Courier{} + + iex> get_courier_by_email("unknown@example.com") + nil """ - def create_restaurant(attrs \\ %{}) do - %Restaurant{} - |> Restaurant.changeset(attrs) - |> Repo.insert() + def get_courier_by_email(email) when is_binary(email) do + Repo.get_by(Courier, email: email) end @doc """ - Updates a restaurant. + Gets a courier by email and password. ## Examples - iex> update_restaurant(restaurant, %{field: new_value}) - {:ok, %Restaurant{}} + iex> get_courier_by_email_and_password("foo@example.com", "correct_password") + %Courier{} - iex> update_restaurant(restaurant, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> get_courier_by_email_and_password("foo@example.com", "invalid_password") + nil """ - def update_restaurant(%Restaurant{} = restaurant, attrs) do - restaurant - |> Restaurant.changeset(attrs) - |> Repo.update() + def get_courier_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + courier = Repo.get_by(Courier, email: email) + if Courier.valid_password?(courier, password), do: courier end + ## Courier registration + @doc """ - Deletes a restaurant. + Registers a courier. ## Examples - iex> delete_restaurant(restaurant) - {:ok, %Restaurant{}} + iex> register_courier(%{field: value}) + {:ok, %Courier{}} - iex> delete_restaurant(restaurant) + iex> register_courier(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ - def delete_restaurant(%Restaurant{} = restaurant) do - Repo.delete(restaurant) + def register_courier(attrs) do + %Courier{} + |> Courier.registration_changeset(attrs) + |> Repo.insert() end @doc """ - Returns an `%Ecto.Changeset{}` for tracking restaurant changes. + Returns an `%Ecto.Changeset{}` for tracking courier changes. ## Examples - iex> change_restaurant(restaurant) - %Ecto.Changeset{data: %Restaurant{}} + iex> change_courier_registration(courier) + %Ecto.Changeset{data: %Courier{}} """ - def change_restaurant(%Restaurant{} = restaurant, attrs \\ %{}) do - Restaurant.changeset(restaurant, attrs) + def change_courier_registration(%Courier{} = courier, attrs \\ %{}) do + Courier.registration_changeset(courier, attrs, hash_password: false) end - alias Volt.Accounts.Courier + ## Settings @doc """ - Returns the list of couriers. + Returns an `%Ecto.Changeset{}` for changing the courier email. ## Examples - iex> list_couriers() - [%Courier{}, ...] + iex> change_courier_email(courier) + %Ecto.Changeset{data: %Courier{}} """ - def list_couriers do - Repo.all(Courier) + def change_courier_email(courier, attrs \\ %{}) do + Courier.email_changeset(courier, attrs) end @doc """ - Gets a single courier. + Emulates that the email will change without actually changing + it in the database. - Raises `Ecto.NoResultsError` if the Courier does not exist. + ## Examples + + iex> apply_courier_email(courier, "valid password", %{email: ...}) + {:ok, %Courier{}} + + iex> apply_courier_email(courier, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_courier_email(courier, password, attrs) do + courier + |> Courier.email_changeset(attrs) + |> Courier.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the courier email using the given token. + + If the token matches, the courier email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_courier_email(courier, token) do + context = "change:#{courier.email}" + + with {:ok, query} <- CourierToken.verify_change_email_token_query(token, context), + %CourierToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(courier_email_multi(courier, email, context)) do + :ok + else + _ -> :error + end + end + + defp courier_email_multi(courier, email, context) do + changeset = + courier + |> Courier.email_changeset(%{email: email}) + |> Courier.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:courier, changeset) + |> Ecto.Multi.delete_all(:tokens, CourierToken.courier_and_contexts_query(courier, [context])) + end + + @doc """ + Delivers the update email instructions to the given courier. ## Examples - iex> get_courier!(123) - %Courier{} + iex> deliver_courier_update_email_instructions(courier, current_email, &Routes.courier_update_email_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} - iex> get_courier!(456) - ** (Ecto.NoResultsError) + """ + def deliver_courier_update_email_instructions(%Courier{} = courier, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, courier_token} = CourierToken.build_email_token(courier, "change:#{current_email}") + + Repo.insert!(courier_token) + CourierNotifier.deliver_courier_update_email_instructions(courier, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the courier password. + + ## Examples + + iex> change_courier_password(courier) + %Ecto.Changeset{data: %Courier{}} """ - def get_courier!(id), do: Repo.get!(Courier, id) + def change_courier_password(courier, attrs \\ %{}) do + Courier.password_changeset(courier, attrs, hash_password: false) + end @doc """ - Creates a courier. + Updates the courier password. ## Examples - iex> create_courier(%{field: value}) + iex> update_courier_password(courier, "valid password", %{password: ...}) {:ok, %Courier{}} - iex> create_courier(%{field: bad_value}) + iex> update_courier_password(courier, "invalid password", %{password: ...}) {:error, %Ecto.Changeset{}} """ - def create_courier(attrs \\ %{}) do - %Courier{} - |> Courier.changeset(attrs) - |> Repo.insert() + def update_courier_password(courier, password, attrs) do + changeset = + courier + |> Courier.password_changeset(attrs) + |> Courier.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:courier, changeset) + |> Ecto.Multi.delete_all(:tokens, CourierToken.courier_and_contexts_query(courier, :all)) + |> Repo.transaction() + |> case do + {:ok, %{courier: courier}} -> {:ok, courier} + {:error, :courier, changeset, _} -> {:error, changeset} + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the courier profile. + + ## Examples + + iex> change_courier_profile(courier) + %Ecto.Changeset{data: %Courier{}} + + """ + def change_courier_profile(courier, attrs \\ %{}) do + Courier.profile_changeset(courier, attrs) end @doc """ - Updates a courier. + Updates the courier profile. ## Examples - iex> update_courier(courier, %{field: new_value}) + iex> update_courier_profile(courier, "valid password", %{password: ...}) {:ok, %Courier{}} - iex> update_courier(courier, %{field: bad_value}) + iex> update_courier_profile(courier, "invalid password", %{password: ...}) {:error, %Ecto.Changeset{}} """ - def update_courier(%Courier{} = courier, attrs) do - courier - |> Courier.changeset(attrs) - |> Repo.update() + def update_courier_profile(courier, password, attrs) do + changeset = + courier + |> Courier.profile_changeset(attrs) + |> Courier.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:courier, changeset) + |> Ecto.Multi.delete_all(:tokens, CourierToken.courier_and_contexts_query(courier, :all)) + |> Repo.transaction() + |> case do + {:ok, %{courier: courier}} -> {:ok, courier} + {:error, :courier, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_courier_session_token(courier) do + {token, courier_token} = CourierToken.build_session_token(courier) + Repo.insert!(courier_token) + token + end + + @doc """ + Gets the courier with the given signed token. + """ + def get_courier_by_session_token(token) do + {:ok, query} = CourierToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_courier_session_token(token) do + Repo.delete_all(CourierToken.token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc """ + Delivers the confirmation email instructions to the given courier. + + ## Examples + + iex> deliver_courier_confirmation_instructions(courier, &Routes.courier_confirmation_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + iex> deliver_courier_confirmation_instructions(confirmed_courier, &Routes.courier_confirmation_url(conn, :edit, &1)) + {:error, :already_confirmed} + + """ + def deliver_courier_confirmation_instructions(%Courier{} = courier, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if courier.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, courier_token} = CourierToken.build_email_token(courier, "confirm") + Repo.insert!(courier_token) + CourierNotifier.deliver_confirmation_instructions(courier, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a courier by the given token. + + If the token matches, the courier account is marked as confirmed + and the token is deleted. + """ + def confirm_courier(token) do + with {:ok, query} <- CourierToken.verify_email_token_query(token, "confirm"), + %Courier{} = courier <- Repo.one(query), + {:ok, %{courier: courier}} <- Repo.transaction(confirm_courier_multi(courier)) do + {:ok, courier} + else + _ -> :error + end + end + + defp confirm_courier_multi(courier) do + Ecto.Multi.new() + |> Ecto.Multi.update(:courier, Courier.confirm_changeset(courier)) + |> Ecto.Multi.delete_all(:tokens, CourierToken.courier_and_contexts_query(courier, ["confirm"])) + end + + ## Reset password + + @doc """ + Delivers the reset password email to the given courier. + + ## Examples + + iex> deliver_courier_reset_password_instructions(courier, &Routes.courier_reset_password_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_courier_reset_password_instructions(%Courier{} = courier, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, courier_token} = CourierToken.build_email_token(courier, "reset_password") + Repo.insert!(courier_token) + CourierNotifier.deliver_reset_password_instructions(courier, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the courier by reset password token. + + ## Examples + + iex> get_courier_by_reset_password_token("validtoken") + %Courier{} + + iex> get_courier_by_reset_password_token("invalidtoken") + nil + + """ + def get_courier_by_reset_password_token(token) do + with {:ok, query} <- CourierToken.verify_email_token_query(token, "reset_password"), + %Courier{} = courier <- Repo.one(query) do + courier + else + _ -> nil + end end @doc """ - Deletes a courier. + Resets the courier password. ## Examples - iex> delete_courier(courier) + iex> reset_courier_password(courier, %{password: "new long password", password_confirmation: "new long password"}) {:ok, %Courier{}} - iex> delete_courier(courier) + iex> reset_courier_password(courier, %{password: "valid", password_confirmation: "not the same"}) {:error, %Ecto.Changeset{}} """ - def delete_courier(%Courier{} = courier) do - Repo.delete(courier) + def reset_courier_password(courier, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:courier, Courier.password_changeset(courier, attrs)) + |> Ecto.Multi.delete_all(:tokens, CourierToken.courier_and_contexts_query(courier, :all)) + |> Repo.transaction() + |> case do + {:ok, %{courier: courier}} -> {:ok, courier} + {:error, :courier, changeset, _} -> {:error, changeset} + end end + alias Volt.Accounts.{Restaurant, RestaurantToken, RestaurantNotifier} + + ## Database getters + @doc """ - Returns an `%Ecto.Changeset{}` for tracking courier changes. + Gets a restaurant by email. ## Examples - iex> change_courier(courier) - %Ecto.Changeset{data: %Courier{}} + iex> get_restaurant_by_email("foo@example.com") + %Restaurant{} + + iex> get_restaurant_by_email("unknown@example.com") + nil """ - def change_courier(%Courier{} = courier, attrs \\ %{}) do - Courier.changeset(courier, attrs) + def get_restaurant_by_email(email) when is_binary(email) do + Repo.get_by(Restaurant, email: email) end - alias Volt.Accounts.Customer - @doc """ - Returns the list of customers. + Gets a restaurant by email and password. ## Examples - iex> list_customers() - [%Customer{}, ...] + iex> get_restaurant_by_email_and_password("foo@example.com", "correct_password") + %Restaurant{} + + iex> get_restaurant_by_email_and_password("foo@example.com", "invalid_password") + nil """ - def list_customers do - Repo.all(Customer) + def get_restaurant_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + restaurant = Repo.get_by(Restaurant, email: email) + if Restaurant.valid_password?(restaurant, password), do: restaurant end @doc """ - Gets a single customer. + Gets a single restaurant. - Raises `Ecto.NoResultsError` if the Customer does not exist. + Raises `Ecto.NoResultsError` if the Restaurant does not exist. ## Examples - iex> get_customer!(123) - %Customer{} + iex> get_restaurant!(123) + %Restaurant{} - iex> get_customer!(456) + iex> get_restaurant!(456) ** (Ecto.NoResultsError) """ - def get_customer!(id), do: Repo.get!(Customer, id) + def get_restaurant!(id), do: Repo.get!(Restaurant, id) + + ## Restaurant registration @doc """ - Creates a customer. + Registers a restaurant. ## Examples - iex> create_customer(%{field: value}) - {:ok, %Customer{}} + iex> register_restaurant(%{field: value}) + {:ok, %Restaurant{}} - iex> create_customer(%{field: bad_value}) + iex> register_restaurant(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ - def create_customer(attrs \\ %{}) do - %Customer{} - |> Customer.changeset(attrs) + def register_restaurant(attrs) do + %Restaurant{} + |> Restaurant.registration_changeset(attrs) |> Repo.insert() end @doc """ - Updates a customer. + Returns an `%Ecto.Changeset{}` for tracking restaurant changes. ## Examples - iex> update_customer(customer, %{field: new_value}) - {:ok, %Customer{}} + iex> change_restaurant_registration(restaurant) + %Ecto.Changeset{data: %Restaurant{}} - iex> update_customer(customer, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + def change_restaurant_registration(%Restaurant{} = restaurant, attrs \\ %{}) do + Restaurant.registration_changeset(restaurant, attrs, hash_password: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the restaurant email. + + ## Examples + + iex> change_restaurant_email(restaurant) + %Ecto.Changeset{data: %Restaurant{}} """ - def update_customer(%Customer{} = customer, attrs) do - customer - |> Customer.changeset(attrs) - |> Repo.update() + def change_restaurant_email(restaurant, attrs \\ %{}) do + Restaurant.email_changeset(restaurant, attrs) end @doc """ - Deletes a customer. + # TODO: email should be changed for real + Emulates that the email will change without actually changing + it in the database. ## Examples - iex> delete_customer(customer) - {:ok, %Customer{}} + iex> apply_restaurant_email(restaurant, "valid password", %{email: ...}) + {:ok, %Restaurant{}} - iex> delete_customer(customer) + iex> apply_restaurant_email(restaurant, "invalid password", %{email: ...}) {:error, %Ecto.Changeset{}} """ - def delete_customer(%Customer{} = customer) do - Repo.delete(customer) + def apply_restaurant_email(restaurant, password, attrs) do + restaurant + |> Restaurant.email_changeset(attrs) + |> Restaurant.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking customer changes. + Updates the restaurant email using the given token. + + If the token matches, the restaurant email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_restaurant_email(restaurant, token) do + context = "change:#{restaurant.email}" + + with {:ok, query} <- RestaurantToken.verify_change_email_token_query(token, context), + %RestaurantToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(restaurant_email_multi(restaurant, email, context)) do + :ok + else + _ -> :error + end + end + + defp restaurant_email_multi(restaurant, email, context) do + changeset = + restaurant + |> Restaurant.email_changeset(%{email: email}) + |> Restaurant.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:restaurant, changeset) + |> Ecto.Multi.delete_all(:tokens, RestaurantToken.restaurant_and_contexts_query(restaurant, [context])) + end + + @doc """ + Delivers the update email instructions to the given restaurant. ## Examples - iex> change_customer(customer) - %Ecto.Changeset{data: %Customer{}} + iex> deliver_restaurant_update_email_instructions(restaurant, current_email, &Routes.restaurant_update_email_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} """ - def change_customer(%Customer{} = customer, attrs \\ %{}) do - Customer.changeset(customer, attrs) + def deliver_restaurant_update_email_instructions(%Restaurant{} = restaurant, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, restaurant_token} = RestaurantToken.build_email_token(restaurant, "change:#{current_email}") + + Repo.insert!(restaurant_token) + RestaurantNotifier.deliver_restaurant_update_email_instructions(restaurant, update_email_url_fun.(encoded_token)) end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the restaurant profile. + + ## Examples + + iex> change_restaurant_profile(restaurant) + %Ecto.Changeset{data: %Restaurant{}} + + """ + def change_restaurant_profile(restaurant, attrs \\ %{}) do + Restaurant.profile_changeset(restaurant, attrs) + end + + @doc """ + Updates the restaurant profile. + + ## Examples + + iex> update_restaurant_profile(restaurant, "valid password", %{password: ...}) + {:ok, %Restaurant{}} + + iex> update_restaurant_profile(restaurant, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_restaurant_profile(restaurant, password, attrs) do + changeset = + restaurant + |> Restaurant.profile_changeset(attrs) + |> Restaurant.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:restaurant, changeset) + |> Ecto.Multi.delete_all(:tokens, RestaurantToken.restaurant_and_contexts_query(restaurant, :all)) + |> Repo.transaction() + |> case do + {:ok, %{restaurant: restaurant}} -> {:ok, restaurant} + {:error, :restaurant, changeset, _} -> {:error, changeset} + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the restaurant password. + + ## Examples + + iex> change_restaurant_password(restaurant) + %Ecto.Changeset{data: %Restaurant{}} + + """ + def change_restaurant_password(restaurant, attrs \\ %{}) do + Restaurant.password_changeset(restaurant, attrs, hash_password: false) + end + + @doc """ + Updates the restaurant password. + + ## Examples + + iex> update_restaurant_password(restaurant, "valid password", %{password: ...}) + {:ok, %Restaurant{}} + + iex> update_restaurant_password(restaurant, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_restaurant_password(restaurant, password, attrs) do + changeset = + restaurant + |> Restaurant.password_changeset(attrs) + |> Restaurant.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:restaurant, changeset) + |> Ecto.Multi.delete_all(:tokens, RestaurantToken.restaurant_and_contexts_query(restaurant, :all)) + |> Repo.transaction() + |> case do + {:ok, %{restaurant: restaurant}} -> {:ok, restaurant} + {:error, :restaurant, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_restaurant_session_token(restaurant) do + {token, restaurant_token} = RestaurantToken.build_session_token(restaurant) + Repo.insert!(restaurant_token) + token + end + + @doc """ + Gets the restaurant with the given signed token. + """ + def get_restaurant_by_session_token(token) do + {:ok, query} = RestaurantToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_restaurant_session_token(token) do + Repo.delete_all(RestaurantToken.token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc """ + Delivers the confirmation email instructions to the given restaurant. + + ## Examples + + iex> deliver_restaurant_confirmation_instructions(restaurant, &Routes.restaurant_confirmation_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + iex> deliver_restaurant_confirmation_instructions(confirmed_restaurant, &Routes.restaurant_confirmation_url(conn, :edit, &1)) + {:error, :already_confirmed} + + """ + def deliver_restaurant_confirmation_instructions(%Restaurant{} = restaurant, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if restaurant.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, restaurant_token} = RestaurantToken.build_email_token(restaurant, "confirm") + Repo.insert!(restaurant_token) + RestaurantNotifier.deliver_confirmation_instructions(restaurant, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a restaurant by the given token. + + If the token matches, the restaurant account is marked as confirmed + and the token is deleted. + """ + def confirm_restaurant(token) do + with {:ok, query} <- RestaurantToken.verify_email_token_query(token, "confirm"), + %Restaurant{} = restaurant <- Repo.one(query), + {:ok, %{restaurant: restaurant}} <- Repo.transaction(confirm_restaurant_multi(restaurant)) do + {:ok, restaurant} + else + _ -> :error + end + end + + defp confirm_restaurant_multi(restaurant) do + Ecto.Multi.new() + |> Ecto.Multi.update(:restaurant, Restaurant.confirm_changeset(restaurant)) + |> Ecto.Multi.delete_all(:tokens, RestaurantToken.restaurant_and_contexts_query(restaurant, ["confirm"])) + end + + ## Reset password + + @doc """ + Delivers the reset password email to the given restaurant. + + ## Examples + + iex> deliver_restaurant_reset_password_instructions(restaurant, &Routes.restaurant_reset_password_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_restaurant_reset_password_instructions(%Restaurant{} = restaurant, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, restaurant_token} = RestaurantToken.build_email_token(restaurant, "reset_password") + Repo.insert!(restaurant_token) + RestaurantNotifier.deliver_reset_password_instructions(restaurant, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the restaurant by reset password token. + + ## Examples + + iex> get_restaurant_by_reset_password_token("validtoken") + %Restaurant{} + + iex> get_restaurant_by_reset_password_token("invalidtoken") + nil + + """ + def get_restaurant_by_reset_password_token(token) do + with {:ok, query} <- RestaurantToken.verify_email_token_query(token, "reset_password"), + %Restaurant{} = restaurant <- Repo.one(query) do + restaurant + else + _ -> nil + end + end + + @doc """ + Resets the restaurant password. + + ## Examples + + iex> reset_restaurant_password(restaurant, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %Restaurant{}} + + iex> reset_restaurant_password(restaurant, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_restaurant_password(restaurant, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:restaurant, Restaurant.password_changeset(restaurant, attrs)) + |> Ecto.Multi.delete_all(:tokens, RestaurantToken.restaurant_and_contexts_query(restaurant, :all)) + |> Repo.transaction() + |> case do + {:ok, %{restaurant: restaurant}} -> {:ok, restaurant} + {:error, :restaurant, changeset, _} -> {:error, changeset} + end + end + + alias Volt.Accounts.{Customer, CustomerToken, CustomerNotifier} + + ## Database getters + + @doc """ + Gets a customer by email. + + ## Examples + + iex> get_customer_by_email("foo@example.com") + %Customer{} + + iex> get_customer_by_email("unknown@example.com") + nil + + """ + def get_customer_by_email(email) when is_binary(email) do + Repo.get_by(Customer, email: email) + end + + @doc """ + Gets a customer by email and password. + + ## Examples + + iex> get_customer_by_email_and_password("foo@example.com", "correct_password") + %Customer{} + + iex> get_customer_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_customer_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + customer = Repo.get_by(Customer, email: email) + if Customer.valid_password?(customer, password), do: customer + end + + @doc """ + Gets a single customer. + + Raises `Ecto.NoResultsError` if the Customer does not exist. + + ## Examples + + iex> get_customer!(123) + %Customer{} + + iex> get_customer!(456) + ** (Ecto.NoResultsError) + + """ + def get_customer!(id), do: Repo.get!(Customer, id) + + ## Customer registration + + @doc """ + Registers a customer. + + ## Examples + + iex> register_customer(%{field: value}) + {:ok, %Customer{}} + + iex> register_customer(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_customer(attrs) do + %Customer{} + |> Customer.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking customer changes. + + ## Examples + + iex> change_customer_registration(customer) + %Ecto.Changeset{data: %Customer{}} + + """ + def change_customer_registration(%Customer{} = customer, attrs \\ %{}) do + Customer.registration_changeset(customer, attrs, hash_password: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the customer profile. + + ## Examples + + iex> change_customer_profile(customer) + %Ecto.Changeset{data: %Customer{}} + + """ + def change_customer_profile(customer, attrs \\ %{}) do + Customer.profile_changeset(customer, attrs) + end + + @doc """ + Updates the customer profile. + + ## Examples + + iex> update_customer_profile(customer, "valid password", %{password: ...}) + {:ok, %Customer{}} + + iex> update_customer_profile(customer, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_customer_profile(customer, password, attrs) do + changeset = + customer + |> Customer.profile_changeset(attrs) + |> Customer.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:customer, changeset) + |> Ecto.Multi.delete_all(:tokens, CustomerToken.customer_and_contexts_query(customer, :all)) + |> Repo.transaction() + |> case do + {:ok, %{customer: customer}} -> {:ok, customer} + {:error, :customer, changeset, _} -> {:error, changeset} + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the customer email. + + ## Examples + + iex> change_customer_email(customer) + %Ecto.Changeset{data: %Customer{}} + + """ + def change_customer_email(customer, attrs \\ %{}) do + Customer.email_changeset(customer, attrs) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_customer_email(customer, "valid password", %{email: ...}) + {:ok, %Customer{}} + + iex> apply_customer_email(customer, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_customer_email(customer, password, attrs) do + customer + |> Customer.email_changeset(attrs) + |> Customer.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the customer email using the given token. + + If the token matches, the customer email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_customer_email(customer, token) do + context = "change:#{customer.email}" + + with {:ok, query} <- CustomerToken.verify_change_email_token_query(token, context), + %CustomerToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(customer_email_multi(customer, email, context)) do + :ok + else + _ -> :error + end + end + + defp customer_email_multi(customer, email, context) do + changeset = + customer + |> Customer.email_changeset(%{email: email}) + |> Customer.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:customer, changeset) + |> Ecto.Multi.delete_all(:tokens, CustomerToken.customer_and_contexts_query(customer, [context])) + end + + @doc """ + Delivers the update email instructions to the given customer. + + ## Examples + + iex> deliver_customer_update_email_instructions(customer, current_email, &Routes.customer_update_email_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_customer_update_email_instructions(%Customer{} = customer, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, customer_token} = CustomerToken.build_email_token(customer, "change:#{current_email}") + + Repo.insert!(customer_token) + CustomerNotifier.deliver_customer_update_email_instructions(customer, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the customer password. + + ## Examples + + iex> change_customer_password(customer) + %Ecto.Changeset{data: %Customer{}} + + """ + def change_customer_password(customer, attrs \\ %{}) do + Customer.password_changeset(customer, attrs, hash_password: false) + end + + @doc """ + Updates the customer password. + + ## Examples + + iex> update_customer_password(customer, "valid password", %{password: ...}) + {:ok, %Customer{}} + + iex> update_customer_password(customer, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_customer_password(customer, password, attrs) do + changeset = + customer + |> Customer.password_changeset(attrs) + |> Customer.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:customer, changeset) + |> Ecto.Multi.delete_all(:tokens, CustomerToken.customer_and_contexts_query(customer, :all)) + |> Repo.transaction() + |> case do + {:ok, %{customer: customer}} -> {:ok, customer} + {:error, :customer, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_customer_session_token(customer) do + {token, customer_token} = CustomerToken.build_session_token(customer) + Repo.insert!(customer_token) + token + end + + @doc """ + Gets the customer with the given signed token. + """ + def get_customer_by_session_token(token) do + {:ok, query} = CustomerToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_customer_session_token(token) do + Repo.delete_all(CustomerToken.token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc """ + Delivers the confirmation email instructions to the given customer. + + ## Examples + + iex> deliver_customer_confirmation_instructions(customer, &Routes.customer_confirmation_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + iex> deliver_customer_confirmation_instructions(confirmed_customer, &Routes.customer_confirmation_url(conn, :edit, &1)) + {:error, :already_confirmed} + + """ + def deliver_customer_confirmation_instructions(%Customer{} = customer, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if customer.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, customer_token} = CustomerToken.build_email_token(customer, "confirm") + Repo.insert!(customer_token) + CustomerNotifier.deliver_confirmation_instructions(customer, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a customer by the given token. + + If the token matches, the customer account is marked as confirmed + and the token is deleted. + """ + def confirm_customer(token) do + with {:ok, query} <- CustomerToken.verify_email_token_query(token, "confirm"), + %Customer{} = customer <- Repo.one(query), + {:ok, %{customer: customer}} <- Repo.transaction(confirm_customer_multi(customer)) do + {:ok, customer} + else + _ -> :error + end + end + + defp confirm_customer_multi(customer) do + Ecto.Multi.new() + |> Ecto.Multi.update(:customer, Customer.confirm_changeset(customer)) + |> Ecto.Multi.delete_all(:tokens, CustomerToken.customer_and_contexts_query(customer, ["confirm"])) + end + + ## Reset password + + @doc """ + Delivers the reset password email to the given customer. + + ## Examples + + iex> deliver_customer_reset_password_instructions(customer, &Routes.customer_reset_password_url(conn, :edit, &1)) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_customer_reset_password_instructions(%Customer{} = customer, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, customer_token} = CustomerToken.build_email_token(customer, "reset_password") + Repo.insert!(customer_token) + CustomerNotifier.deliver_reset_password_instructions(customer, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the customer by reset password token. + + ## Examples + + iex> get_customer_by_reset_password_token("validtoken") + %Customer{} + + iex> get_customer_by_reset_password_token("invalidtoken") + nil + + """ + def get_customer_by_reset_password_token(token) do + with {:ok, query} <- CustomerToken.verify_email_token_query(token, "reset_password"), + %Customer{} = customer <- Repo.one(query) do + customer + else + _ -> nil + end + end + + @doc """ + Resets the customer password. + + ## Examples + + iex> reset_customer_password(customer, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %Customer{}} + + iex> reset_customer_password(customer, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_customer_password(customer, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:customer, Customer.password_changeset(customer, attrs)) + |> Ecto.Multi.delete_all(:tokens, CustomerToken.customer_and_contexts_query(customer, :all)) + |> Repo.transaction() + |> case do + {:ok, %{customer: customer}} -> {:ok, customer} + {:error, :customer, changeset, _} -> {:error, changeset} + end + end + + """ + TODO: Revise old tests + + def create_restaurant(attrs \\ %{}) do + %Restaurant{} + |> Restaurant.changeset(attrs) + |> Repo.insert() + end + + + def update_restaurant(%Restaurant{} = restaurant, attrs) do + restaurant + |> Restaurant.changeset(attrs) + |> Repo.update() + end + + def delete_restaurant(%Restaurant{} = restaurant) do + Repo.delete(restaurant) + end + + + def change_restaurant(%Restaurant{} = restaurant, attrs \\ %{}) do + Restaurant.changeset(restaurant, attrs) + end + + alias Volt.Accounts.Courier + + + def list_couriers do + Repo.all(Courier) + end + + + def get_courier!(id), do: Repo.get!(Courier, id) + + + def create_courier(attrs \\ %{}) do + %Courier{} + |> Courier.changeset(attrs) + |> Repo.insert() + end + + + def update_courier(%Courier{} = courier, attrs) do + courier + |> Courier.changeset(attrs) + |> Repo.update() + end + + + def delete_courier(%Courier{} = courier) do + Repo.delete(courier) + end + + + def change_courier(%Courier{} = courier, attrs \\ %{}) do + Courier.changeset(courier, attrs) + end + + alias Volt.Accounts.Customer + + + def list_customers do + Repo.all(Customer) + end + + + def create_customer(attrs \\ %{}) do + %Customer{} + |> Customer.changeset(attrs) + |> Repo.insert() + end + + + def update_customer(%Customer{} = customer, attrs) do + customer + |> Customer.changeset(attrs) + |> Repo.update() + end + + + def delete_customer(%Customer{} = customer) do + Repo.delete(customer) + end + + + def change_customer(%Customer{} = customer, attrs \\ %{}) do + Customer.changeset(customer, attrs) + end + """ + end diff --git a/lib/volt/accounts/courier.ex b/lib/volt/accounts/courier.ex index 605288baaef7a49c9229c2c19832efaba3ea632a..582c830aef94dfa9ac75a3d8c006b183e57f93a2 100644 --- a/lib/volt/accounts/courier.ex +++ b/lib/volt/accounts/courier.ex @@ -7,21 +7,153 @@ defmodule Volt.Accounts.Courier do field :email, :string field :first_name, :string field :last_name, :string - field :password, :string field :phone_number, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :naive_datetime timestamps() end - @doc false - def changeset(courier, attrs) do + @doc """ + A courier 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(courier, attrs, opts \\ []) do courier - |> cast(attrs, [:email, :password, :first_name, :last_name, :phone_number, :courier_status]) - |> validate_required([:email, :password, :first_name, :last_name, :phone_number, :courier_status]) - |> validate_format(:phone_number, ~r/^\+(\d{1,3}?)\d{10}$/, message: "Invalid phone number") - |> validate_format(:email, ~r/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/, message: "Invalid email address") - |> validate_length(:password, min: 8) - |> unique_constraint(:email, name: :courier_unique_email_constraint) + |> cast(attrs, [:email, :password, :first_name, :last_name, :phone_number]) + |> validate_profile() + |> validate_email() + |> validate_password(opts) + end + + defp validate_profile(changeset) do + changeset + |> validate_required([:first_name, :last_name, :phone_number]) + |> validate_format(:phone_number, ~r/^\+(\d{1,3})?\d{10}$/, message: "Invalid phone number. Should start with plus and from 10 to 13 digits") |> unique_constraint(:phone_number, name: :courier_unique_phone_number_constraint) end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, Volt.Repo) + |> unique_constraint(:email) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, 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 + |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + A courier changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(courier, attrs) do + courier + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A courier 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(courier, attrs, opts \\ []) do + courier + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + A courier changeset for changing the profile. + """ + def profile_changeset(courier, attrs) do + courier + |> cast(attrs, [:first_name, :last_name, :phone_number]) + |> validate_profile() + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(courier) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(courier, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no courier or the courier doesn't have a password, we call + `Pbkdf2.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Volt.Accounts.Courier{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Pbkdf2.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Pbkdf2.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/volt/accounts/courier_notifier.ex b/lib/volt/accounts/courier_notifier.ex new file mode 100644 index 0000000000000000000000000000000000000000..2a400e8b29c7339b3652f58e74f444e84837b75d --- /dev/null +++ b/lib/volt/accounts/courier_notifier.ex @@ -0,0 +1,79 @@ +defmodule Volt.Accounts.CourierNotifier do + import Swoosh.Email + + alias Volt.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Volt", "contact@example.com"}) + |> 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(courier, url) do + deliver(courier.email, "Confirmation instructions", """ + + ============================== + + Hi #{courier.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 courier password. + """ + def deliver_reset_password_instructions(courier, url) do + deliver(courier.email, "Reset password instructions", """ + + ============================== + + Hi #{courier.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 courier email. + """ + def deliver_courier_update_email_instructions(courier, url) do + deliver(courier.email, "Update email instructions", """ + + ============================== + + Hi #{courier.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/volt/accounts/courier_token.ex b/lib/volt/accounts/courier_token.ex new file mode 100644 index 0000000000000000000000000000000000000000..3262541e1f2da0f36b93e4f9fb0667af86b73278 --- /dev/null +++ b/lib/volt/accounts/courier_token.ex @@ -0,0 +1,179 @@ +defmodule Volt.Accounts.CourierToken do + use Ecto.Schema + import Ecto.Query + alias Volt.Accounts.CourierToken + + @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 "couriers_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :courier, Volt.Accounts.Courier + + 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 courier + 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(courier) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %CourierToken{token: token, context: "session", courier_id: courier.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the courier 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: courier in assoc(token, :courier), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: courier + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the courier's email. + + The non-hashed token is sent to the courier 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(courier, context) do + build_hashed_token(courier, context, courier.email) + end + + defp build_hashed_token(courier, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %CourierToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + courier_id: courier.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the courier 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: courier in assoc(token, :courier), + where: token.inserted_at > ago(^days, "day") and token.sent_to == courier.email, + select: courier + + {: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 courier found by the token, if any. + + This is used to validate requests to change the courier + 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 CourierToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given courier for the given contexts. + """ + def courier_and_contexts_query(courier, :all) do + from t in CourierToken, where: t.courier_id == ^courier.id + end + + def courier_and_contexts_query(courier, [_ | _] = contexts) do + from t in CourierToken, where: t.courier_id == ^courier.id and t.context in ^contexts + end +end diff --git a/lib/volt/accounts/customer.ex b/lib/volt/accounts/customer.ex index 2cbd5977272d985dd7d5e60c6798c3f8c126ec46..b009ef15ecb1b620d1e255a458d43467db5f62b4 100644 --- a/lib/volt/accounts/customer.ex +++ b/lib/volt/accounts/customer.ex @@ -12,23 +12,155 @@ defmodule Volt.Accounts.Customer do field :email, :string field :first_name, :string field :last_name, :string - field :password, :string field :phone_number, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :naive_datetime timestamps() end - @doc false - def changeset(customer, attrs) do + @doc """ + A customer 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(customer, attrs, opts \\ []) do customer |> cast(attrs, [:email, :password, :first_name, :last_name, :phone_number, :birth_date, :address, :zip_code, :city, :card_number, :balance]) - |> validate_required([:email, :password, :first_name, :last_name, :phone_number, :birth_date, :address, :zip_code, :city, :card_number, :balance]) - |> validate_format(:phone_number, ~r/^\+(\d{1,3}?)\d{10}$/, message: "Invalid phone number") + |> validate_profile() + |> validate_email() + |> validate_password(opts) + end + + defp validate_profile(changeset) do + changeset + |> validate_required([:first_name, :last_name, :phone_number, :birth_date, :address, :zip_code, :city, :card_number, :balance]) + |> validate_format(:phone_number, ~r/^\+(\d{1,3})?\d{10}$/, message: "Invalid phone number. Should start with plus and from 10 to 13 digits") |> validate_format(:card_number, ~r/[0-9]{13,}/, message: "Card number must be at least 13 digits") - |> validate_format(:email, ~r/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/, message: "Invalid email address") - |> validate_length(:password, min: 8) |> validate_format(:zip_code, ~r/^\d{5}$/, message: "Zip code must be 5 digits") - |> unique_constraint(:email, name: :customer_unique_email_constraint) |> unique_constraint(:phone_number, name: :customer_unique_phone_number_constraint) end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, Volt.Repo) + |> unique_constraint(:email) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, 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 + |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + A customer changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(customer, attrs) do + customer + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A customer 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(customer, attrs, opts \\ []) do + customer + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + A customer changeset for changing the profile. + """ + def profile_changeset(customer, attrs) do + customer + |> cast(attrs, [:first_name, :last_name, :phone_number, :birth_date, :address, :zip_code, :city, :card_number, :balance]) + |> validate_profile() + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(customer) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(customer, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no customer or the customer doesn't have a password, we call + `Pbkdf2.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Volt.Accounts.Customer{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Pbkdf2.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Pbkdf2.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/volt/accounts/customer_notifier.ex b/lib/volt/accounts/customer_notifier.ex new file mode 100644 index 0000000000000000000000000000000000000000..12e8aac12e3bc2281ac29b134acc352e1f4d4da6 --- /dev/null +++ b/lib/volt/accounts/customer_notifier.ex @@ -0,0 +1,79 @@ +defmodule Volt.Accounts.CustomerNotifier do + import Swoosh.Email + + alias Volt.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Volt", "contact@example.com"}) + |> 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(customer, url) do + deliver(customer.email, "Confirmation instructions", """ + + ============================== + + Hi #{customer.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 customer password. + """ + def deliver_reset_password_instructions(customer, url) do + deliver(customer.email, "Reset password instructions", """ + + ============================== + + Hi #{customer.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 customer email. + """ + def deliver_customer_update_email_instructions(customer, url) do + deliver(customer.email, "Update email instructions", """ + + ============================== + + Hi #{customer.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/volt/accounts/customer_token.ex b/lib/volt/accounts/customer_token.ex new file mode 100644 index 0000000000000000000000000000000000000000..e8573e9d702d5eb808e10f8af44641dd4948b6a8 --- /dev/null +++ b/lib/volt/accounts/customer_token.ex @@ -0,0 +1,179 @@ +defmodule Volt.Accounts.CustomerToken do + use Ecto.Schema + import Ecto.Query + alias Volt.Accounts.CustomerToken + + @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 "customers_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :customer, Volt.Accounts.Customer + + 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 customer + 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(customer) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %CustomerToken{token: token, context: "session", customer_id: customer.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the customer 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: customer in assoc(token, :customer), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: customer + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the customer's email. + + The non-hashed token is sent to the customer 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(customer, context) do + build_hashed_token(customer, context, customer.email) + end + + defp build_hashed_token(customer, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %CustomerToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + customer_id: customer.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the customer 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: customer in assoc(token, :customer), + where: token.inserted_at > ago(^days, "day") and token.sent_to == customer.email, + select: customer + + {: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 customer found by the token, if any. + + This is used to validate requests to change the customer + 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 CustomerToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given customer for the given contexts. + """ + def customer_and_contexts_query(customer, :all) do + from t in CustomerToken, where: t.customer_id == ^customer.id + end + + def customer_and_contexts_query(customer, [_ | _] = contexts) do + from t in CustomerToken, where: t.customer_id == ^customer.id and t.context in ^contexts + end +end diff --git a/lib/volt/accounts/restaurant.ex b/lib/volt/accounts/restaurant.ex index 517fdbec725730c6f6cf6340ad4e72498f552a10..097593ec2e45ffdf3368be610342c0d823efafd5 100644 --- a/lib/volt/accounts/restaurant.ex +++ b/lib/volt/accounts/restaurant.ex @@ -12,17 +12,156 @@ defmodule Volt.Accounts.Restaurant do field :last_name, :string field :name, :string field :opening_time, :time - field :password, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :naive_datetime field :phone_number, :string field :price_level, :integer + has_many :items, Volt.Sales.Item timestamps() end - @doc false - def changeset(restaurant, attrs) do + @doc """ + A restaurant 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(restaurant, attrs, opts \\ []) do restaurant |> cast(attrs, [:email, :password, :first_name, :last_name, :phone_number, :name, :address, :city, :zip_code, :price_level, :opening_time, :closing_time]) - |> validate_required([:email, :password, :first_name, :last_name, :phone_number, :name, :address, :price_level, :opening_time, :closing_time, :zip_code, :city]) + |> validate_profile() + |> validate_email() + |> validate_password(opts) + end + + defp validate_profile(changeset) do + changeset + |> validate_required([:first_name, :last_name, :phone_number, :name, :address, :price_level, :opening_time, :closing_time, :zip_code, :city]) + |> validate_format(:phone_number, ~r/^\+(\d{1,3})?\d{10}$/, message: "Invalid phone number. Should start with plus and from 10 to 13 digits") + |> validate_format(:zip_code, ~r/^\d{5}$/, message: "Zip code must be 5 digits") + |> unique_constraint(:phone_number, name: :customer_unique_phone_number_constraint) + end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, Volt.Repo) + |> unique_constraint(:email) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, 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 + |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + A restaurant changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(restaurant, attrs) do + restaurant + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A restaurant 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(restaurant, attrs, opts \\ []) do + restaurant + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + A restaurant changeset for changing the profile. + """ + def profile_changeset(restaurant, attrs) do + restaurant + |> cast(attrs, [:first_name, :last_name, :phone_number, :name, :address, :city, :zip_code, :price_level, :opening_time, :closing_time]) + |> validate_profile() + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(restaurant) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(restaurant, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no restaurant or the restaurant doesn't have a password, we call + `Pbkdf2.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Volt.Accounts.Restaurant{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Pbkdf2.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Pbkdf2.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/volt/accounts/restaurant_notifier.ex b/lib/volt/accounts/restaurant_notifier.ex new file mode 100644 index 0000000000000000000000000000000000000000..bec4c96104a22e3b0cf7e7b5462729b6903bb568 --- /dev/null +++ b/lib/volt/accounts/restaurant_notifier.ex @@ -0,0 +1,79 @@ +defmodule Volt.Accounts.RestaurantNotifier do + import Swoosh.Email + + alias Volt.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Volt", "contact@example.com"}) + |> 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(restaurant, url) do + deliver(restaurant.email, "Confirmation instructions", """ + + ============================== + + Hi #{restaurant.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 restaurant password. + """ + def deliver_reset_password_instructions(restaurant, url) do + deliver(restaurant.email, "Reset password instructions", """ + + ============================== + + Hi #{restaurant.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 restaurant email. + """ + def deliver_restaurant_update_email_instructions(restaurant, url) do + deliver(restaurant.email, "Update email instructions", """ + + ============================== + + Hi #{restaurant.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/volt/accounts/restaurant_token.ex b/lib/volt/accounts/restaurant_token.ex new file mode 100644 index 0000000000000000000000000000000000000000..796d2fad765ac0babcb7520e5b8e95dd35a3a8d5 --- /dev/null +++ b/lib/volt/accounts/restaurant_token.ex @@ -0,0 +1,179 @@ +defmodule Volt.Accounts.RestaurantToken do + use Ecto.Schema + import Ecto.Query + alias Volt.Accounts.RestaurantToken + + @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 "restaurants_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :restaurant, Volt.Accounts.Restaurant + + 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 restaurant + 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(restaurant) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %RestaurantToken{token: token, context: "session", restaurant_id: restaurant.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the restaurant 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: restaurant in assoc(token, :restaurant), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: restaurant + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the restaurant's email. + + The non-hashed token is sent to the restaurant 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(restaurant, context) do + build_hashed_token(restaurant, context, restaurant.email) + end + + defp build_hashed_token(restaurant, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %RestaurantToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + restaurant_id: restaurant.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the restaurant 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: restaurant in assoc(token, :restaurant), + where: token.inserted_at > ago(^days, "day") and token.sent_to == restaurant.email, + select: restaurant + + {: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 restaurant found by the token, if any. + + This is used to validate requests to change the restaurant + 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 RestaurantToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given restaurant for the given contexts. + """ + def restaurant_and_contexts_query(restaurant, :all) do + from t in RestaurantToken, where: t.restaurant_id == ^restaurant.id + end + + def restaurant_and_contexts_query(restaurant, [_ | _] = contexts) do + from t in RestaurantToken, where: t.restaurant_id == ^restaurant.id and t.context in ^contexts + end +end diff --git a/lib/volt/sales.ex b/lib/volt/sales.ex new file mode 100644 index 0000000000000000000000000000000000000000..70423d2ccec793bedf115c0a0ff775926218e634 --- /dev/null +++ b/lib/volt/sales.ex @@ -0,0 +1,104 @@ +defmodule Volt.Sales do + @moduledoc """ + The Sales context. + """ + + import Ecto.Query, warn: false + alias Volt.Repo + + alias Volt.Sales.Item + + @doc """ + Returns the list of items. + + ## Examples + + iex> list_items() + [%Item{}, ...] + + """ + def list_items do + Repo.all(Item) + end + + @doc """ + Gets a single item. + + Raises `Ecto.NoResultsError` if the Item does not exist. + + ## Examples + + iex> get_item!(123) + %Item{} + + iex> get_item!(456) + ** (Ecto.NoResultsError) + + """ + def get_item!(id), do: Repo.get!(Item, id) + + @doc """ + Creates a item. + + ## Examples + + iex> create_item(%{field: value}) + {:ok, %Item{}} + + iex> create_item(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_item(attrs \\ %{}) do + %Item{} + |> Item.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a item. + + ## Examples + + iex> update_item(item, %{field: new_value}) + {:ok, %Item{}} + + iex> update_item(item, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_item(%Item{} = item, attrs) do + item + |> Item.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a item. + + ## Examples + + iex> delete_item(item) + {:ok, %Item{}} + + iex> delete_item(item) + {:error, %Ecto.Changeset{}} + + """ + def delete_item(%Item{} = item) do + Repo.delete(item) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking item changes. + + ## Examples + + iex> change_item(item) + %Ecto.Changeset{data: %Item{}} + + """ + def change_item(%Item{} = item, attrs \\ %{}) do + Item.changeset(item, attrs) + end +end diff --git a/lib/volt/sales/item.ex b/lib/volt/sales/item.ex new file mode 100644 index 0000000000000000000000000000000000000000..d22e6cf99a83745853af301c595bab1f998c5c51 --- /dev/null +++ b/lib/volt/sales/item.ex @@ -0,0 +1,20 @@ +defmodule Volt.Sales.Item do + use Ecto.Schema + import Ecto.Changeset + + schema "items" do + field :description, :string + field :name, :string + field :unit_price, :string + belongs_to :restaurant, Volt.Accounts.Restaurant + + timestamps() + end + + @doc false + def changeset(item, attrs) do + item + |> cast(attrs, [:name, :description, :unit_price]) + |> validate_required([:name, :unit_price]) + end +end diff --git a/lib/volt_web/controllers/courier_auth.ex b/lib/volt_web/controllers/courier_auth.ex new file mode 100644 index 0000000000000000000000000000000000000000..50b90357cfcf71193b6f0935b47e232f4bf3bab2 --- /dev/null +++ b/lib/volt_web/controllers/courier_auth.ex @@ -0,0 +1,149 @@ +defmodule VoltWeb.CourierAuth do + import Plug.Conn + import Phoenix.Controller + + alias Volt.Accounts + alias VoltWeb.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 CourierToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_volt_web_courier_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the courier 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_courier(conn, courier, params \\ %{}) do + token = Accounts.generate_courier_session_token(courier) + courier_return_to = get_session(conn, :courier_return_to) + + conn + |> renew_session() + |> put_session(:courier_token, token) + |> put_session(:live_socket_id, "couriers_sessions:#{Base.url_encode64(token)}") + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: courier_return_to || signed_in_path(conn)) + 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 courier out. + + It clears all session data for safety. See renew_session. + """ + def log_out_courier(conn) do + courier_token = get_session(conn, :courier_token) + courier_token && Accounts.delete_courier_session_token(courier_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + VoltWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: "/") + end + + @doc """ + Authenticates the courier by looking into the session + and remember me token. + """ + def fetch_current_courier(conn, _opts) do + {courier_token, conn} = ensure_courier_token(conn) + courier = courier_token && Accounts.get_courier_by_session_token(courier_token) + assign(conn, :current_courier, courier) + end + + defp ensure_courier_token(conn) do + if courier_token = get_session(conn, :courier_token) do + {courier_token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if courier_token = conn.cookies[@remember_me_cookie] do + {courier_token, put_session(conn, :courier_token, courier_token)} + else + {nil, conn} + end + end + end + + @doc """ + Used for routes that require the courier to not be authenticated. + """ + def redirect_if_courier_is_authenticated(conn, _opts) do + if conn.assigns[:current_courier] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the courier to be authenticated. + + If you want to enforce the courier email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_courier(conn, _opts) do + if conn.assigns[:current_courier] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: Routes.courier_session_path(conn, :new)) + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :courier_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: "/couriers/dashboard" +end diff --git a/lib/volt_web/controllers/courier_confirmation_controller.ex b/lib/volt_web/controllers/courier_confirmation_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..50a24f5a0b927ae4c264b680cd7c3e735ba8680a --- /dev/null +++ b/lib/volt_web/controllers/courier_confirmation_controller.ex @@ -0,0 +1,56 @@ +defmodule VoltWeb.CourierConfirmationController do + use VoltWeb, :controller + + alias Volt.Accounts + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"courier" => %{"email" => email}}) do + if courier = Accounts.get_courier_by_email(email) do + Accounts.deliver_courier_confirmation_instructions( + courier, + &Routes.courier_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 courier after confirmation to avoid a + # leaked token giving the courier access to the account. + def update(conn, %{"token" => token}) do + case Accounts.confirm_courier(token) do + {:ok, _} -> + conn + |> put_flash(:info, "Courier confirmed successfully.") + |> redirect(to: "/") + + :error -> + # If there is a current courier and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the courier themselves, so we redirect without + # a warning message. + case conn.assigns do + %{current_courier: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: "/") + + %{} -> + conn + |> put_flash(:error, "Courier confirmation link is invalid or it has expired.") + |> redirect(to: "/") + end + end + end +end diff --git a/lib/volt_web/controllers/courier_controller.ex b/lib/volt_web/controllers/courier_controller.ex deleted file mode 100644 index 0d093fce59a3ad32f0fd41e10aa3314570c5bd92..0000000000000000000000000000000000000000 --- a/lib/volt_web/controllers/courier_controller.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule VoltWeb.CourierController do - use VoltWeb, :controller - - alias Volt.Accounts - alias Volt.Accounts.Courier - - def index(conn, _params) do - couriers = Accounts.list_couriers() - render(conn, "index.html", couriers: couriers) - end - - def new(conn, _params) do - changeset = Accounts.change_courier(%Courier{}) - render(conn, "new.html", changeset: changeset) - end - - def create(conn, %{"courier" => courier_params}) do - case Accounts.create_courier(courier_params) do - {:ok, courier} -> - conn - |> put_flash(:info, "Courier created successfully.") - |> redirect(to: Routes.courier_path(conn, :show, courier)) - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) - end - end - - def show(conn, %{"id" => id}) do - courier = Accounts.get_courier!(id) - render(conn, "show.html", courier: courier) - end - - def edit(conn, %{"id" => id}) do - courier = Accounts.get_courier!(id) - changeset = Accounts.change_courier(courier) - render(conn, "edit.html", courier: courier, changeset: changeset) - end - - def update(conn, %{"id" => id, "courier" => courier_params}) do - courier = Accounts.get_courier!(id) - - case Accounts.update_courier(courier, courier_params) do - {:ok, courier} -> - conn - |> put_flash(:info, "Courier updated successfully.") - |> redirect(to: Routes.courier_path(conn, :show, courier)) - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "edit.html", courier: courier, changeset: changeset) - end - end - - def delete(conn, %{"id" => id}) do - courier = Accounts.get_courier!(id) - {:ok, _courier} = Accounts.delete_courier(courier) - - conn - |> put_flash(:info, "Courier deleted successfully.") - |> redirect(to: Routes.courier_path(conn, :index)) - end -end diff --git a/lib/volt_web/controllers/courier_dashboard_controller.ex b/lib/volt_web/controllers/courier_dashboard_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..34b7bbb41ce4335dd32ea7ac1795634283063832 --- /dev/null +++ b/lib/volt_web/controllers/courier_dashboard_controller.ex @@ -0,0 +1,7 @@ +defmodule VoltWeb.CourierDashboardController do + use VoltWeb, :controller + + def index(conn, _params) do + render(conn, "index.html") + end +end diff --git a/lib/volt_web/controllers/courier_registration_controller.ex b/lib/volt_web/controllers/courier_registration_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..a1e1f623faca27298581d82122b01a80c2b808c0 --- /dev/null +++ b/lib/volt_web/controllers/courier_registration_controller.ex @@ -0,0 +1,30 @@ +defmodule VoltWeb.CourierRegistrationController do + use VoltWeb, :controller + + alias Volt.Accounts + alias Volt.Accounts.Courier + alias VoltWeb.CourierAuth + + def new(conn, _params) do + changeset = Accounts.change_courier_registration(%Courier{}) + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"courier" => courier_params}) do + case Accounts.register_courier(courier_params) do + {:ok, courier} -> + {:ok, _} = + Accounts.deliver_courier_confirmation_instructions( + courier, + &Routes.courier_confirmation_url(conn, :edit, &1) + ) + + conn + |> put_flash(:info, "Courier created successfully.") + |> CourierAuth.log_in_courier(courier) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end +end diff --git a/lib/volt_web/controllers/courier_reset_password_controller.ex b/lib/volt_web/controllers/courier_reset_password_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..dd32253070a4f3be032e3a042645aa904cc82e5a --- /dev/null +++ b/lib/volt_web/controllers/courier_reset_password_controller.ex @@ -0,0 +1,58 @@ +defmodule VoltWeb.CourierResetPasswordController do + use VoltWeb, :controller + + alias Volt.Accounts + + plug :get_courier_by_reset_password_token when action in [:edit, :update] + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"courier" => %{"email" => email}}) do + if courier = Accounts.get_courier_by_email(email) do + Accounts.deliver_courier_reset_password_instructions( + courier, + &Routes.courier_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_courier_password(conn.assigns.courier)) + end + + # Do not log in the courier after reset password to avoid a + # leaked token giving the courier access to the account. + def update(conn, %{"courier" => courier_params}) do + case Accounts.reset_courier_password(conn.assigns.courier, courier_params) do + {:ok, _} -> + conn + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: Routes.courier_session_path(conn, :new)) + + {:error, changeset} -> + render(conn, "edit.html", changeset: changeset) + end + end + + defp get_courier_by_reset_password_token(conn, _opts) do + %{"token" => token} = conn.params + + if courier = Accounts.get_courier_by_reset_password_token(token) do + conn |> assign(:courier, courier) |> 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/volt_web/controllers/courier_session_controller.ex b/lib/volt_web/controllers/courier_session_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..33cd154dc41675e6ffc88d1b2e8c1e4e12b37ccb --- /dev/null +++ b/lib/volt_web/controllers/courier_session_controller.ex @@ -0,0 +1,27 @@ +defmodule VoltWeb.CourierSessionController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.CourierAuth + + def new(conn, _params) do + render(conn, "new.html", error_message: nil) + end + + def create(conn, %{"courier" => courier_params}) do + %{"email" => email, "password" => password} = courier_params + + if courier = Accounts.get_courier_by_email_and_password(email, password) do + CourierAuth.log_in_courier(conn, courier, courier_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.") + |> CourierAuth.log_out_courier() + end +end diff --git a/lib/volt_web/controllers/courier_settings_controller.ex b/lib/volt_web/controllers/courier_settings_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..9b6844452cd9626ba3e4ba8e343dc9ad90d89cb4 --- /dev/null +++ b/lib/volt_web/controllers/courier_settings_controller.ex @@ -0,0 +1,91 @@ +defmodule VoltWeb.CourierSettingsController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.CourierAuth + + plug :assign_changesets + + def edit(conn, _params) do + render(conn, "edit.html") + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"current_password" => password, "courier" => courier_params} = params + courier = conn.assigns.current_courier + + case Accounts.apply_courier_email(courier, password, courier_params) do + {:ok, applied_courier} -> + Accounts.deliver_courier_update_email_instructions( + applied_courier, + courier.email, + &Routes.courier_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.courier_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, "courier" => courier_params} = params + courier = conn.assigns.current_courier + + case Accounts.update_courier_password(courier, password, courier_params) do + {:ok, courier} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:courier_return_to, Routes.courier_settings_path(conn, :edit)) + |> CourierAuth.log_in_courier(courier) + + {:error, changeset} -> + render(conn, "edit.html", password_changeset: changeset) + end + end + + def update(conn, %{"action" => "update_profile"} = params) do + %{"current_password" => password, "courier" => courier_params} = params + courier = conn.assigns.current_courier + + case Accounts.update_courier_profile(courier, password, courier_params) do + {:ok, courier} -> + conn + |> put_flash(:info, "Profile updated successfully.") + |> put_session(:courier_return_to, Routes.courier_settings_path(conn, :edit)) + |> CourierAuth.log_in_courier(courier) + + {:error, changeset} -> + render(conn, "edit.html", profile_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_courier_email(conn.assigns.current_courier, token) do + :ok -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: Routes.courier_settings_path(conn, :edit)) + + :error -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: Routes.courier_settings_path(conn, :edit)) + end + end + + defp assign_changesets(conn, _opts) do + courier = conn.assigns.current_courier + + conn + |> assign(:email_changeset, Accounts.change_courier_email(courier)) + |> assign(:password_changeset, Accounts.change_courier_password(courier)) + |> assign(:profile_changeset, Accounts.change_courier_profile(courier)) + end +end diff --git a/lib/volt_web/controllers/customer_auth.ex b/lib/volt_web/controllers/customer_auth.ex new file mode 100644 index 0000000000000000000000000000000000000000..e3f1af2afd6c35f1cc274c0474425b1ab9f4ddeb --- /dev/null +++ b/lib/volt_web/controllers/customer_auth.ex @@ -0,0 +1,149 @@ +defmodule VoltWeb.CustomerAuth do + import Plug.Conn + import Phoenix.Controller + + alias Volt.Accounts + alias VoltWeb.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 CustomerToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_volt_web_customer_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the customer 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_customer(conn, customer, params \\ %{}) do + token = Accounts.generate_customer_session_token(customer) + customer_return_to = get_session(conn, :customer_return_to) + + conn + |> renew_session() + |> put_session(:customer_token, token) + |> put_session(:live_socket_id, "customers_sessions:#{Base.url_encode64(token)}") + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: customer_return_to || signed_in_path(conn)) + 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 customer out. + + It clears all session data for safety. See renew_session. + """ + def log_out_customer(conn) do + customer_token = get_session(conn, :customer_token) + customer_token && Accounts.delete_customer_session_token(customer_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + VoltWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: "/") + end + + @doc """ + Authenticates the customer by looking into the session + and remember me token. + """ + def fetch_current_customer(conn, _opts) do + {customer_token, conn} = ensure_customer_token(conn) + customer = customer_token && Accounts.get_customer_by_session_token(customer_token) + assign(conn, :current_customer, customer) + end + + defp ensure_customer_token(conn) do + if customer_token = get_session(conn, :customer_token) do + {customer_token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if customer_token = conn.cookies[@remember_me_cookie] do + {customer_token, put_session(conn, :customer_token, customer_token)} + else + {nil, conn} + end + end + end + + @doc """ + Used for routes that require the customer to not be authenticated. + """ + def redirect_if_customer_is_authenticated(conn, _opts) do + if conn.assigns[:current_customer] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the customer to be authenticated. + + If you want to enforce the customer email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_customer(conn, _opts) do + if conn.assigns[:current_customer] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: Routes.customer_session_path(conn, :new)) + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :customer_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: "/customers/dashboard" +end diff --git a/lib/volt_web/controllers/customer_confirmation_controller.ex b/lib/volt_web/controllers/customer_confirmation_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..e0881fd9592571c1a3a214523943bd92478a0066 --- /dev/null +++ b/lib/volt_web/controllers/customer_confirmation_controller.ex @@ -0,0 +1,56 @@ +defmodule VoltWeb.CustomerConfirmationController do + use VoltWeb, :controller + + alias Volt.Accounts + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"customer" => %{"email" => email}}) do + if customer = Accounts.get_customer_by_email(email) do + Accounts.deliver_customer_confirmation_instructions( + customer, + &Routes.customer_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 customer after confirmation to avoid a + # leaked token giving the customer access to the account. + def update(conn, %{"token" => token}) do + case Accounts.confirm_customer(token) do + {:ok, _} -> + conn + |> put_flash(:info, "Customer confirmed successfully.") + |> redirect(to: "/") + + :error -> + # If there is a current customer and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the customer themselves, so we redirect without + # a warning message. + case conn.assigns do + %{current_customer: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: "/") + + %{} -> + conn + |> put_flash(:error, "Customer confirmation link is invalid or it has expired.") + |> redirect(to: "/") + end + end + end +end diff --git a/lib/volt_web/controllers/customer_controller.ex b/lib/volt_web/controllers/customer_controller.ex deleted file mode 100644 index 038a17b54f936b9eda0002e988732e9eda30fb67..0000000000000000000000000000000000000000 --- a/lib/volt_web/controllers/customer_controller.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule VoltWeb.CustomerController do - use VoltWeb, :controller - - alias Volt.Accounts - alias Volt.Accounts.Customer - - def index(conn, _params) do - customers = Accounts.list_customers() - render(conn, "index.html", customers: customers) - end - - def new(conn, _params) do - changeset = Accounts.change_customer(%Customer{}) - render(conn, "new.html", changeset: changeset) - end - - def create(conn, %{"customer" => customer_params}) do - case Accounts.create_customer(customer_params) do - {:ok, customer} -> - conn - |> put_flash(:info, "Customer created successfully.") - |> redirect(to: Routes.customer_path(conn, :show, customer)) - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) - end - end - - def show(conn, %{"id" => id}) do - customer = Accounts.get_customer!(id) - render(conn, "show.html", customer: customer) - end - - def edit(conn, %{"id" => id}) do - customer = Accounts.get_customer!(id) - changeset = Accounts.change_customer(customer) - render(conn, "edit.html", customer: customer, changeset: changeset) - end - - def update(conn, %{"id" => id, "customer" => customer_params}) do - customer = Accounts.get_customer!(id) - - case Accounts.update_customer(customer, customer_params) do - {:ok, customer} -> - conn - |> put_flash(:info, "Customer updated successfully.") - |> redirect(to: Routes.customer_path(conn, :show, customer)) - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "edit.html", customer: customer, changeset: changeset) - end - end - - def delete(conn, %{"id" => id}) do - customer = Accounts.get_customer!(id) - {:ok, _customer} = Accounts.delete_customer(customer) - - conn - |> put_flash(:info, "Customer deleted successfully.") - |> redirect(to: Routes.customer_path(conn, :index)) - end -end diff --git a/lib/volt_web/controllers/customer_dashboard_controller.ex b/lib/volt_web/controllers/customer_dashboard_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..aa5c64955e082fd81860c0621d538b4a2dcf6c1e --- /dev/null +++ b/lib/volt_web/controllers/customer_dashboard_controller.ex @@ -0,0 +1,33 @@ +defmodule VoltWeb.CustomerDashboardController do + use VoltWeb, :controller + import Volt.Geolocation + import Ecto.Query, only: [from: 2] + alias Volt.Accounts.Restaurant + alias Volt.Repo + + defmodule Restaurant_summary do + defstruct [:id, :name, :dist, :price_level] + end + + defp customer_restaurants(conn) do + usr = conn.assigns.current_customer + current_user_address = usr.address <> ", " <> usr.zip_code <> ", " <> usr.city + restaurants = + from(rest in Restaurant, select: {rest.id, rest.name, rest.address, rest.zip_code, rest.city, rest.price_level}) + |> Repo.all() + |> Enum.map(fn {id, name, address, zip_code, city, price_level} -> + rest_address = address <> ", " <> zip_code <> ", " <> city + [dist, _] = distance(rest_address, current_user_address) + %{id: id, name: name, dist: dist, price_level: price_level} + end) + |> Enum.sort_by(fn(rest) -> {rest.dist} end) + |> Enum.map(fn %{id: id, name: name, dist: dist, price_level: price_level} -> + %Restaurant_summary{id: id, name: name, dist: dist, price_level: price_level} + end) + restaurants + end + + def index(conn, _params) do + render(conn, "index.html", restaurants: customer_restaurants(conn)) + end +end diff --git a/lib/volt_web/controllers/customer_registration_controller.ex b/lib/volt_web/controllers/customer_registration_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..5e465d35d29b2029469e250901419d25218a28c3 --- /dev/null +++ b/lib/volt_web/controllers/customer_registration_controller.ex @@ -0,0 +1,30 @@ +defmodule VoltWeb.CustomerRegistrationController do + use VoltWeb, :controller + + alias Volt.Accounts + alias Volt.Accounts.Customer + alias VoltWeb.CustomerAuth + + def new(conn, _params) do + changeset = Accounts.change_customer_registration(%Customer{}) + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"customer" => customer_params}) do + case Accounts.register_customer(customer_params) do + {:ok, customer} -> + {:ok, _} = + Accounts.deliver_customer_confirmation_instructions( + customer, + &Routes.customer_confirmation_url(conn, :edit, &1) + ) + + conn + |> put_flash(:info, "Customer created successfully.") + |> CustomerAuth.log_in_customer(customer) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end +end diff --git a/lib/volt_web/controllers/customer_reset_password_controller.ex b/lib/volt_web/controllers/customer_reset_password_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..4942962c75e3dcf2f9d3b4cd69d0f2649f5802a8 --- /dev/null +++ b/lib/volt_web/controllers/customer_reset_password_controller.ex @@ -0,0 +1,58 @@ +defmodule VoltWeb.CustomerResetPasswordController do + use VoltWeb, :controller + + alias Volt.Accounts + + plug :get_customer_by_reset_password_token when action in [:edit, :update] + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"customer" => %{"email" => email}}) do + if customer = Accounts.get_customer_by_email(email) do + Accounts.deliver_customer_reset_password_instructions( + customer, + &Routes.customer_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_customer_password(conn.assigns.customer)) + end + + # Do not log in the customer after reset password to avoid a + # leaked token giving the customer access to the account. + def update(conn, %{"customer" => customer_params}) do + case Accounts.reset_customer_password(conn.assigns.customer, customer_params) do + {:ok, _} -> + conn + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: Routes.customer_session_path(conn, :new)) + + {:error, changeset} -> + render(conn, "edit.html", changeset: changeset) + end + end + + defp get_customer_by_reset_password_token(conn, _opts) do + %{"token" => token} = conn.params + + if customer = Accounts.get_customer_by_reset_password_token(token) do + conn |> assign(:customer, customer) |> 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/volt_web/controllers/customer_session_controller.ex b/lib/volt_web/controllers/customer_session_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..183654693e96b3281f66dfdb15008890c1aec683 --- /dev/null +++ b/lib/volt_web/controllers/customer_session_controller.ex @@ -0,0 +1,27 @@ +defmodule VoltWeb.CustomerSessionController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.CustomerAuth + + def new(conn, _params) do + render(conn, "new.html", error_message: nil) + end + + def create(conn, %{"customer" => customer_params}) do + %{"email" => email, "password" => password} = customer_params + + if customer = Accounts.get_customer_by_email_and_password(email, password) do + CustomerAuth.log_in_customer(conn, customer, customer_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.") + |> CustomerAuth.log_out_customer() + end +end diff --git a/lib/volt_web/controllers/customer_settings_controller.ex b/lib/volt_web/controllers/customer_settings_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..f56f1340a258ebc2647506e138ede27b293ae2c6 --- /dev/null +++ b/lib/volt_web/controllers/customer_settings_controller.ex @@ -0,0 +1,91 @@ +defmodule VoltWeb.CustomerSettingsController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.CustomerAuth + + plug :assign_changesets + + def edit(conn, _params) do + render(conn, "edit.html") + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"current_password" => password, "customer" => customer_params} = params + customer = conn.assigns.current_customer + + case Accounts.apply_customer_email(customer, password, customer_params) do + {:ok, applied_customer} -> + Accounts.deliver_customer_update_email_instructions( + applied_customer, + customer.email, + &Routes.customer_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.customer_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, "customer" => customer_params} = params + customer = conn.assigns.current_customer + + case Accounts.update_customer_password(customer, password, customer_params) do + {:ok, customer} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:customer_return_to, Routes.customer_settings_path(conn, :edit)) + |> CustomerAuth.log_in_customer(customer) + + {:error, changeset} -> + render(conn, "edit.html", password_changeset: changeset) + end + end + + def update(conn, %{"action" => "update_profile"} = params) do + %{"current_password" => password, "customer" => customer_params} = params + customer = conn.assigns.current_customer + + case Accounts.update_customer_profile(customer, password, customer_params) do + {:ok, customer} -> + conn + |> put_flash(:info, "Profile updated successfully.") + |> put_session(:customer_return_to, Routes.customer_settings_path(conn, :edit)) + |> CustomerAuth.log_in_customer(customer) + + {:error, changeset} -> + render(conn, "edit.html", profile_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_customer_email(conn.assigns.current_customer, token) do + :ok -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: Routes.customer_settings_path(conn, :edit)) + + :error -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: Routes.customer_settings_path(conn, :edit)) + end + end + + defp assign_changesets(conn, _opts) do + customer = conn.assigns.current_customer + + conn + |> assign(:email_changeset, Accounts.change_customer_email(customer)) + |> assign(:password_changeset, Accounts.change_customer_password(customer)) + |> assign(:profile_changeset, Accounts.change_customer_profile(customer)) + end +end diff --git a/lib/volt_web/controllers/restaurant_auth.ex b/lib/volt_web/controllers/restaurant_auth.ex new file mode 100644 index 0000000000000000000000000000000000000000..7e62a928792ec5c6e5df94b98d5f407500808791 --- /dev/null +++ b/lib/volt_web/controllers/restaurant_auth.ex @@ -0,0 +1,151 @@ +defmodule VoltWeb.RestaurantAuth do + import Plug.Conn + import Phoenix.Controller + + alias Volt.Accounts + alias VoltWeb.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 RestaurantToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_volt_web_restaurant_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the restaurant 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_restaurant(conn, restaurant, params \\ %{}) do + token = Accounts.generate_restaurant_session_token(restaurant) + restaurant_return_to = get_session(conn, :restaurant_return_to) + + # restaurant_return_to = get_session(conn, Routes.restaurant_dashboard_path(conn, :show)) + + conn + |> renew_session() + |> put_session(:restaurant_token, token) + |> put_session(:live_socket_id, "restaurants_sessions:#{Base.url_encode64(token)}") + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: restaurant_return_to || signed_in_path(conn)) + 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 restaurant out. + + It clears all session data for safety. See renew_session. + """ + def log_out_restaurant(conn) do + restaurant_token = get_session(conn, :restaurant_token) + restaurant_token && Accounts.delete_restaurant_session_token(restaurant_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + VoltWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: "/") + end + + @doc """ + Authenticates the restaurant by looking into the session + and remember me token. + """ + def fetch_current_restaurant(conn, _opts) do + {restaurant_token, conn} = ensure_restaurant_token(conn) + restaurant = restaurant_token && Accounts.get_restaurant_by_session_token(restaurant_token) + assign(conn, :current_restaurant, restaurant) + end + + defp ensure_restaurant_token(conn) do + if restaurant_token = get_session(conn, :restaurant_token) do + {restaurant_token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if restaurant_token = conn.cookies[@remember_me_cookie] do + {restaurant_token, put_session(conn, :restaurant_token, restaurant_token)} + else + {nil, conn} + end + end + end + + @doc """ + Used for routes that require the restaurant to not be authenticated. + """ + def redirect_if_restaurant_is_authenticated(conn, _opts) do + if conn.assigns[:current_restaurant] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the restaurant to be authenticated. + + If you want to enforce the restaurant email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_restaurant(conn, _opts) do + if conn.assigns[:current_restaurant] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: Routes.restaurant_session_path(conn, :new)) + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :restaurant_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: "/restaurants/dashboard" +end diff --git a/lib/volt_web/controllers/restaurant_confirmation_controller.ex b/lib/volt_web/controllers/restaurant_confirmation_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..09cdabd6bedec8974da5bd29461319d6bf530a4a --- /dev/null +++ b/lib/volt_web/controllers/restaurant_confirmation_controller.ex @@ -0,0 +1,56 @@ +defmodule VoltWeb.RestaurantConfirmationController do + use VoltWeb, :controller + + alias Volt.Accounts + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"restaurant" => %{"email" => email}}) do + if restaurant = Accounts.get_restaurant_by_email(email) do + Accounts.deliver_restaurant_confirmation_instructions( + restaurant, + &Routes.restaurant_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 restaurant after confirmation to avoid a + # leaked token giving the restaurant access to the account. + def update(conn, %{"token" => token}) do + case Accounts.confirm_restaurant(token) do + {:ok, _} -> + conn + |> put_flash(:info, "Restaurant confirmed successfully.") + |> redirect(to: "/") + + :error -> + # If there is a current restaurant and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the restaurant themselves, so we redirect without + # a warning message. + case conn.assigns do + %{current_restaurant: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: "/") + + %{} -> + conn + |> put_flash(:error, "Restaurant confirmation link is invalid or it has expired.") + |> redirect(to: "/") + end + end + end +end diff --git a/lib/volt_web/controllers/restaurant_controller.ex b/lib/volt_web/controllers/restaurant_controller.ex deleted file mode 100644 index 228a35799e39615e7e40538eb9651c3853200c6e..0000000000000000000000000000000000000000 --- a/lib/volt_web/controllers/restaurant_controller.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule VoltWeb.RestaurantController do - use VoltWeb, :controller - - alias Volt.Accounts - alias Volt.Accounts.Restaurant - - - def new(conn, _params) do - changeset = Accounts.change_restaurant(%Restaurant{}) - render(conn, "new.html", changeset: changeset) - end - - def create(conn, %{"restaurant" => restaurant_params}) do - case Accounts.create_restaurant(restaurant_params) do - {:ok, restaurant} -> - conn - |> put_flash(:info, "Restaurant created successfully.") - |> redirect(to: Routes.restaurant_path(conn, :show, restaurant)) - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) - end - end - - def show(conn, %{"id" => id}) do - restaurant = Accounts.get_restaurant!(id) - render(conn, "index.html", restaurant: restaurant) - end - - def edit(conn, %{"id" => id}) do - restaurant = Accounts.get_restaurant!(id) - changeset = Accounts.change_restaurant(restaurant) - render(conn, "edit.html", restaurant: restaurant, changeset: changeset) - end - - def update(conn, %{"id" => id, "restaurant" => restaurant_params}) do - restaurant = Accounts.get_restaurant!(id) - - case Accounts.update_restaurant(restaurant, restaurant_params) do - {:ok, _restaurant} -> - conn - |> put_flash(:info, "Restaurant updated successfully.") - |> redirect(to: Routes.restaurant_path(conn, :show, id)) - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "edit.html", restaurant: restaurant, changeset: changeset) - end - end - - def delete(conn, %{"id" => id}) do - restaurant = Accounts.get_restaurant!(id) - {:ok, _restaurant} = Accounts.delete_restaurant(restaurant) - - conn - |> put_flash(:info, "Restaurant deleted successfully.") - |> redirect(to: Routes.restaurant_path(conn, :index)) - end -end diff --git a/lib/volt_web/controllers/restaurant_dashboard_controller.ex b/lib/volt_web/controllers/restaurant_dashboard_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..88c3aede593cd77c6c02467124ff1d4547ea655c --- /dev/null +++ b/lib/volt_web/controllers/restaurant_dashboard_controller.ex @@ -0,0 +1,74 @@ +defmodule VoltWeb.RestaurantDashboardController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.RestaurantAuth + + plug :assign_email_and_password_changesets + + def index(conn, _params) do + render(conn, "index.html") + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"current_password" => password, "restaurant" => restaurant_params} = params + restaurant = conn.assigns.current_restaurant + + case Accounts.apply_restaurant_email(restaurant, password, restaurant_params) do + {:ok, applied_restaurant} -> + Accounts.deliver_restaurant_update_email_instructions( + applied_restaurant, + restaurant.email, + &Routes.restaurant_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.restaurant_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, "restaurant" => restaurant_params} = params + restaurant = conn.assigns.current_restaurant + + case Accounts.update_restaurant_password(restaurant, password, restaurant_params) do + {:ok, restaurant} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:restaurant_return_to, Routes.restaurant_settings_path(conn, :edit)) + |> RestaurantAuth.log_in_restaurant(restaurant) + + {:error, changeset} -> + render(conn, "edit.html", password_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_restaurant_email(conn.assigns.current_restaurant, token) do + :ok -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: Routes.restaurant_settings_path(conn, :edit)) + + :error -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: Routes.restaurant_settings_path(conn, :edit)) + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + restaurant = conn.assigns.current_restaurant + + conn + |> assign(:email_changeset, Accounts.change_restaurant_email(restaurant)) + |> assign(:password_changeset, Accounts.change_restaurant_password(restaurant)) + end +end diff --git a/lib/volt_web/controllers/restaurant_item_controller.ex b/lib/volt_web/controllers/restaurant_item_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..21ff8becfdade18ae350a4964bbde801a83cdcda --- /dev/null +++ b/lib/volt_web/controllers/restaurant_item_controller.ex @@ -0,0 +1,86 @@ +defmodule VoltWeb.RestaurantItemController do + use VoltWeb, :controller + + import Ecto.Query, only: [from: 2] + + alias Volt.Sales + alias Volt.Sales.Item + alias Volt.Repo + + def index(conn, _params) do + restaurant = conn.assigns.current_restaurant + items = + from(i in Item, where: i.restaurant_id == ^restaurant.id) + |> Repo.all() + |> Repo.preload(:restaurant) + render(conn, "index.html", items: items) + end + + def new(conn, _params) do + changeset = Sales.change_item(%Item{}) + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"item" => item_params}) do + restaurant = conn.assigns.current_restaurant + + item_struct = Ecto.build_assoc(restaurant, :items, Enum.map(item_params, fn({key, value}) -> {String.to_atom(key), value} end)) + changeset = Item.changeset(item_struct, %{}) + case Repo.insert(changeset) do + {:ok, item} -> + conn + |> put_flash(:info, "Item created successfully.") + |> redirect(to: Routes.restaurant_item_path(conn, :show, item)) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + # item = Sales.get_item!(id) |> Repo.preload(:restaurant) + item = + from(i in Item, where: i.id == ^id) + |> Repo.one() + |> Repo.preload(:restaurant) + render(conn, "show.html", item: item) + end + + def edit(conn, %{"id" => id}) do + item = + from(i in Item, where: i.id == ^id) + |> Repo.one() + |> Repo.preload(:restaurant) + changeset = Sales.change_item(item) + render(conn, "edit.html", item: item, changeset: changeset) + end + + def update(conn, %{"id" => id, "item" => item_params}) do + item = + from(i in Item, where: i.id == ^id) + |> Repo.one() + |> Repo.preload(:restaurant) + + case Sales.update_item(item, item_params) do + {:ok, item} -> + conn + |> put_flash(:info, "Item updated successfully.") + |> redirect(to: Routes.restaurant_item_path(conn, :show, item)) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", item: item, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + item = + from(i in Item, where: i.id == ^id) + |> Repo.one() + |> Repo.preload(:restaurant) + {:ok, _item} = Sales.delete_item(item) + + conn + |> put_flash(:info, "Item deleted successfully.") + |> redirect(to: Routes.restaurant_item_path(conn, :index)) + end +end diff --git a/lib/volt_web/controllers/restaurant_registration_controller.ex b/lib/volt_web/controllers/restaurant_registration_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..32ac23f2edcbb1e366b7251afde13718146b7709 --- /dev/null +++ b/lib/volt_web/controllers/restaurant_registration_controller.ex @@ -0,0 +1,30 @@ +defmodule VoltWeb.RestaurantRegistrationController do + use VoltWeb, :controller + + alias Volt.Accounts + alias Volt.Accounts.Restaurant + alias VoltWeb.RestaurantAuth + + def new(conn, _params) do + changeset = Accounts.change_restaurant_registration(%Restaurant{}) + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"restaurant" => restaurant_params}) do + case Accounts.register_restaurant(restaurant_params) do + {:ok, restaurant} -> + {:ok, _} = + Accounts.deliver_restaurant_confirmation_instructions( + restaurant, + &Routes.restaurant_confirmation_url(conn, :edit, &1) + ) + + conn + |> put_flash(:info, "Restaurant created successfully.") + |> RestaurantAuth.log_in_restaurant(restaurant) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + end + end +end diff --git a/lib/volt_web/controllers/restaurant_reset_password_controller.ex b/lib/volt_web/controllers/restaurant_reset_password_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..e85e9730dc12d1e08e210a83c1dcab58a5c191b2 --- /dev/null +++ b/lib/volt_web/controllers/restaurant_reset_password_controller.ex @@ -0,0 +1,58 @@ +defmodule VoltWeb.RestaurantResetPasswordController do + use VoltWeb, :controller + + alias Volt.Accounts + + plug :get_restaurant_by_reset_password_token when action in [:edit, :update] + + def new(conn, _params) do + render(conn, "new.html") + end + + def create(conn, %{"restaurant" => %{"email" => email}}) do + if restaurant = Accounts.get_restaurant_by_email(email) do + Accounts.deliver_restaurant_reset_password_instructions( + restaurant, + &Routes.restaurant_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_restaurant_password(conn.assigns.restaurant)) + end + + # Do not log in the restaurant after reset password to avoid a + # leaked token giving the restaurant access to the account. + def update(conn, %{"restaurant" => restaurant_params}) do + case Accounts.reset_restaurant_password(conn.assigns.restaurant, restaurant_params) do + {:ok, _} -> + conn + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: Routes.restaurant_session_path(conn, :new)) + + {:error, changeset} -> + render(conn, "edit.html", changeset: changeset) + end + end + + defp get_restaurant_by_reset_password_token(conn, _opts) do + %{"token" => token} = conn.params + + if restaurant = Accounts.get_restaurant_by_reset_password_token(token) do + conn |> assign(:restaurant, restaurant) |> 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/volt_web/controllers/restaurant_session_controller.ex b/lib/volt_web/controllers/restaurant_session_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..1e2dba81090b6b38e81b896d57ecca125233b3c7 --- /dev/null +++ b/lib/volt_web/controllers/restaurant_session_controller.ex @@ -0,0 +1,27 @@ +defmodule VoltWeb.RestaurantSessionController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.RestaurantAuth + + def new(conn, _params) do + render(conn, "new.html", error_message: nil) + end + + def create(conn, %{"restaurant" => restaurant_params}) do + %{"email" => email, "password" => password} = restaurant_params + + if restaurant = Accounts.get_restaurant_by_email_and_password(email, password) do + RestaurantAuth.log_in_restaurant(conn, restaurant, restaurant_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.") + |> RestaurantAuth.log_out_restaurant() + end +end diff --git a/lib/volt_web/controllers/restaurant_settings_controller.ex b/lib/volt_web/controllers/restaurant_settings_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..a3c556209ab4739ac792ee7c7d3219fe71f1ab27 --- /dev/null +++ b/lib/volt_web/controllers/restaurant_settings_controller.ex @@ -0,0 +1,92 @@ +defmodule VoltWeb.RestaurantSettingsController do + use VoltWeb, :controller + + alias Volt.Accounts + alias VoltWeb.RestaurantAuth + + plug :assign_changesets + + def edit(conn, _params) do + render(conn, "edit.html") + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"current_password" => password, "restaurant" => restaurant_params} = params + restaurant = conn.assigns.current_restaurant + + case Accounts.apply_restaurant_email(restaurant, password, restaurant_params) do + {:ok, applied_restaurant} -> + Accounts.deliver_restaurant_update_email_instructions( + applied_restaurant, + restaurant.email, + &Routes.restaurant_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.restaurant_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, "restaurant" => restaurant_params} = params + restaurant = conn.assigns.current_restaurant + + case Accounts.update_restaurant_password(restaurant, password, restaurant_params) do + {:ok, restaurant} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:restaurant_return_to, Routes.restaurant_settings_path(conn, :edit)) + |> RestaurantAuth.log_in_restaurant(restaurant) + + {:error, changeset} -> + render(conn, "edit.html", password_changeset: changeset) + end + end + + def update(conn, %{"action" => "update_profile"} = params) do + %{"current_password" => password, "restaurant" => restaurant_params} = params + restaurant = conn.assigns.current_restaurant + + case Accounts.update_restaurant_profile(restaurant, password, restaurant_params) do + {:ok, restaurant} -> + conn + |> put_flash(:info, "Profile updated successfully.") + |> put_session(:restaurant_return_to, Routes.restaurant_settings_path(conn, :edit)) + |> RestaurantAuth.log_in_restaurant(restaurant) + + {:error, changeset} -> + render(conn, "edit.html", profile_changeset: changeset) + end + end + + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_restaurant_email(conn.assigns.current_restaurant, token) do + :ok -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: Routes.restaurant_settings_path(conn, :edit)) + + :error -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: Routes.restaurant_settings_path(conn, :edit)) + end + end + + defp assign_changesets(conn, _opts) do + restaurant = conn.assigns.current_restaurant + + conn + |> assign(:email_changeset, Accounts.change_restaurant_email(restaurant)) + |> assign(:password_changeset, Accounts.change_restaurant_password(restaurant)) + |> assign(:profile_changeset, Accounts.change_restaurant_profile(restaurant)) + end +end diff --git a/lib/volt_web/router.ex b/lib/volt_web/router.ex index 91baeb5938591198dd1fb66711f7bfde16ddfdfc..b06a12b1d71345d922af0aa75978d11976117b47 100644 --- a/lib/volt_web/router.ex +++ b/lib/volt_web/router.ex @@ -1,6 +1,12 @@ defmodule VoltWeb.Router do use VoltWeb, :router + import VoltWeb.CustomerAuth + + import VoltWeb.RestaurantAuth + + import VoltWeb.CourierAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,6 +14,9 @@ defmodule VoltWeb.Router do plug :put_root_layout, {VoltWeb.LayoutView, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_customer + plug :fetch_current_restaurant + plug :fetch_current_courier end pipeline :api do @@ -17,9 +26,6 @@ defmodule VoltWeb.Router do scope "/", VoltWeb do pipe_through :browser get "/", PageController, :index - resources "/restaurant", RestaurantController - resources "/courier", CourierController - resources "/customer", CustomerController end @@ -41,7 +47,6 @@ defmodule VoltWeb.Router do scope "/" do pipe_through :browser - live_dashboard "/dashboard", metrics: VoltWeb.Telemetry end end @@ -57,4 +62,116 @@ defmodule VoltWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + + + ## Authentication routes + + scope "/", VoltWeb do + pipe_through [:browser, :redirect_if_courier_is_authenticated] + + get "/couriers/register", CourierRegistrationController, :new + post "/couriers/register", CourierRegistrationController, :create + get "/couriers/log_in", CourierSessionController, :new + post "/couriers/log_in", CourierSessionController, :create + get "/couriers/reset_password", CourierResetPasswordController, :new + post "/couriers/reset_password", CourierResetPasswordController, :create + get "/couriers/reset_password/:token", CourierResetPasswordController, :edit + put "/couriers/reset_password/:token", CourierResetPasswordController, :update + end + + scope "/", VoltWeb do + pipe_through [:browser, :require_authenticated_courier] + + get "/couriers/settings", CourierSettingsController, :edit + put "/couriers/settings", CourierSettingsController, :update + get "/couriers/settings/confirm_email/:token", CourierSettingsController, :confirm_email + get "/couriers/dashboard", CourierDashboardController, :index + end + + scope "/", VoltWeb do + pipe_through [:browser] + + delete "/couriers/log_out", CourierSessionController, :delete + get "/couriers/confirm", CourierConfirmationController, :new + post "/couriers/confirm", CourierConfirmationController, :create + get "/couriers/confirm/:token", CourierConfirmationController, :edit + post "/couriers/confirm/:token", CourierConfirmationController, :update + end + + ## Authentication routes + + scope "/", VoltWeb do + pipe_through [:browser, :redirect_if_restaurant_is_authenticated] + + get "/restaurants/register", RestaurantRegistrationController, :new + post "/restaurants/register", RestaurantRegistrationController, :create + get "/restaurants/log_in", RestaurantSessionController, :new + post "/restaurants/log_in", RestaurantSessionController, :create + get "/restaurants/reset_password", RestaurantResetPasswordController, :new + post "/restaurants/reset_password", RestaurantResetPasswordController, :create + get "/restaurants/reset_password/:token", RestaurantResetPasswordController, :edit + put "/restaurants/reset_password/:token", RestaurantResetPasswordController, :update + end + + scope "/", VoltWeb do + pipe_through [:browser, :require_authenticated_restaurant] + + get "/restaurants/settings", RestaurantSettingsController, :edit + put "/restaurants/settings", RestaurantSettingsController, :update + get "/restaurants/settings/confirm_email/:token", RestaurantSettingsController, :confirm_email + get "/restaurants/dashboard", RestaurantDashboardController, :index + + get "/restaurants/additem", RestaurantItemController, :new + post "/restaurants/additem", RestaurantItemController, :create + get "/restaurants/menu", RestaurantItemController, :index + get "/restaurants/item/:id", RestaurantItemController, :show + get "/restaurants/edit_item/:id", RestaurantItemController, :edit + put "/restaurants/edit_item/:id", RestaurantItemController, :update + delete "/restaurants/delete_item/:id", RestaurantItemController, :delete + end + + scope "/", VoltWeb do + pipe_through [:browser] + + delete "/restaurants/log_out", RestaurantSessionController, :delete + get "/restaurants/confirm", RestaurantConfirmationController, :new + post "/restaurants/confirm", RestaurantConfirmationController, :create + get "/restaurants/confirm/:token", RestaurantConfirmationController, :edit + post "/restaurants/confirm/:token", RestaurantConfirmationController, :update + end + + ## Authentication routes + + scope "/", VoltWeb do + pipe_through [:browser, :redirect_if_customer_is_authenticated] + + get "/customers/register", CustomerRegistrationController, :new + post "/customers/register", CustomerRegistrationController, :create + get "/customers/log_in", CustomerSessionController, :new + post "/customers/log_in", CustomerSessionController, :create + get "/customers/reset_password", CustomerResetPasswordController, :new + post "/customers/reset_password", CustomerResetPasswordController, :create + get "/customers/reset_password/:token", CustomerResetPasswordController, :edit + put "/customers/reset_password/:token", CustomerResetPasswordController, :update + end + + scope "/", VoltWeb do + pipe_through [:browser, :require_authenticated_customer] + + get "/customers/settings", CustomerSettingsController, :edit + put "/customers/settings", CustomerSettingsController, :update + get "/customers/settings/confirm_email/:token", CustomerSettingsController, :confirm_email + get "/customers/dashboard", CustomerDashboardController, :index + end + + scope "/", VoltWeb do + pipe_through [:browser] + + delete "/customers/log_out", CustomerSessionController, :delete + get "/customers/confirm", CustomerConfirmationController, :new + post "/customers/confirm", CustomerConfirmationController, :create + get "/customers/confirm/:token", CustomerConfirmationController, :edit + post "/customers/confirm/:token", CustomerConfirmationController, :update + end end diff --git a/lib/volt_web/services/geolocation.ex b/lib/volt_web/services/geolocation.ex new file mode 100644 index 0000000000000000000000000000000000000000..588d9a0332fcaf7168a87bf57e2d044d9c01955b --- /dev/null +++ b/lib/volt_web/services/geolocation.ex @@ -0,0 +1,22 @@ +defmodule Volt.Geolocation do + + def find_location(address) do + uri = "http://dev.virtualearth.net/REST/v1/Locations?q=1#{URI.encode(address)}%&key=#{get_key()}" + response = HTTPoison.get! uri + matches = Regex.named_captures(~r/coordinates\D+(?<lat>-?\d+.\d+)\D+(?<long>-?\d+.\d+)/, response.body) + [{v1, _}, {v2, _}] = [matches["lat"] |> Float.parse, matches["long"] |> Float.parse] + [v1, v2] + end + + def distance(origin, destination) do + [o1, o2] = find_location(origin) + [d1, d2] = find_location(destination) + uri = "https://dev.virtualearth.net/REST/v1/Routes/DistanceMatrix?origins=#{o1},#{o2}&destinations=#{d1},#{d2}&travelMode=driving&key=#{get_key()}" + response = HTTPoison.get! uri + matches = Regex.named_captures(~r/travelD\D+(?<dist>\d+.\d+)\D+(?<dur>\d+.\d+)/,response.body) + [{v1, _}, {v2, _}] = [matches["dist"] |> Float.parse, matches["dur"] |> Float.parse] + [v1, v2] + end + + defp get_key(), do: System.get_env("BING_MAP_KEY") +end diff --git a/lib/volt_web/templates/courier/edit.html.heex b/lib/volt_web/templates/courier/edit.html.heex deleted file mode 100644 index a657afb276376e20bf30c40926352771e41a43d7..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/courier/edit.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<h1>Edit Courier</h1> - -<%= render "form.html", Map.put(assigns, :action, Routes.courier_path(@conn, :update, @courier)) %> - -<span><%= link "Back", to: Routes.courier_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/courier/index.html.heex b/lib/volt_web/templates/courier/index.html.heex deleted file mode 100644 index 3236996b5c5c21dc22c0b144ea37edbf53ca95cc..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/courier/index.html.heex +++ /dev/null @@ -1,2 +0,0 @@ -<h1>Courier registration</h1> -<span><%= link "New Courier", to: Routes.courier_path(@conn, :new) %></span> diff --git a/lib/volt_web/templates/courier/new.html.heex b/lib/volt_web/templates/courier/new.html.heex deleted file mode 100644 index aeea11fd16c5c100a012dc4ee1995f1b6868ba0c..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/courier/new.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<h1>New Courier</h1> - -<%= render "form.html", Map.put(assigns, :action, Routes.courier_path(@conn, :create)) %> - -<span><%= link "Back", to: Routes.courier_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/courier/show.html.heex b/lib/volt_web/templates/courier/show.html.heex deleted file mode 100644 index 79cb1ce6d454dc38aaecaade72ef4918e8c1db5c..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/courier/show.html.heex +++ /dev/null @@ -1,38 +0,0 @@ -<h1>Show Courier</h1> - -<ul> - - <li> - <strong>Email:</strong> - <%= @courier.email %> - </li> - - <li> - <strong>Password:</strong> - <%= @courier.password %> - </li> - - <li> - <strong>First name:</strong> - <%= @courier.first_name %> - </li> - - <li> - <strong>Last name:</strong> - <%= @courier.last_name %> - </li> - - <li> - <strong>Phone number:</strong> - <%= @courier.phone_number %> - </li> - - <li> - <strong>Courier status:</strong> - <%= @courier.courier_status %> - </li> - -</ul> - -<span><%= link "Edit", to: Routes.courier_path(@conn, :edit, @courier) %></span> | -<span><%= link "Back", to: Routes.courier_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/courier_confirmation/edit.html.heex b/lib/volt_web/templates/courier_confirmation/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..8db32f771a5fcc52e899d1c7b8f4c46c9c0d7026 --- /dev/null +++ b/lib/volt_web/templates/courier_confirmation/edit.html.heex @@ -0,0 +1,12 @@ +<h1>Confirm account</h1> + +<.form let={_f} for={:courier} action={Routes.courier_confirmation_path(@conn, :update, @token)}> + <div> + <%= submit "Confirm my account" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.courier_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.courier_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/courier_confirmation/new.html.heex b/lib/volt_web/templates/courier_confirmation/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..8251d7d3b4bf3d06969ccd5e0422486d0b386415 --- /dev/null +++ b/lib/volt_web/templates/courier_confirmation/new.html.heex @@ -0,0 +1,15 @@ +<h1>Resend confirmation instructions</h1> + +<.form let={f} for={:courier} action={Routes.courier_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.courier_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.courier_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/courier_dashboard/index.html.heex b/lib/volt_web/templates/courier_dashboard/index.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..25a753ca40a383d8f992b48ce369cf7a0113c253 --- /dev/null +++ b/lib/volt_web/templates/courier_dashboard/index.html.heex @@ -0,0 +1 @@ +<h1>Courier Dashboard</h1> \ No newline at end of file diff --git a/lib/volt_web/templates/courier/form.html.heex b/lib/volt_web/templates/courier_registration/new.html.heex similarity index 52% rename from lib/volt_web/templates/courier/form.html.heex rename to lib/volt_web/templates/courier_registration/new.html.heex index 2d46828c4e7d7766edc221be98e234196445853d..4d4d1c7a2f3c73d66026e1f4b43a6d849d275233 100644 --- a/lib/volt_web/templates/courier/form.html.heex +++ b/lib/volt_web/templates/courier_registration/new.html.heex @@ -1,16 +1,18 @@ -<.form let={f} for={@changeset} action={@action}> +<h1>Register</h1> + +<.form let={f} for={@changeset} action={Routes.courier_registration_path(@conn, :create)}> <%= if @changeset.action do %> <div class="alert alert-danger"> - <p>Oops, something went wrong! Please check the errors below.</p> + <p>@changeset Oops, something went wrong! Please check the errors below.</p> </div> <% end %> <%= label f, :email %> - <%= email_input f, :email %> + <%= email_input f, :email, required: true %> <%= error_tag f, :email %> <%= label f, :password %> - <%= password_input f, :password %> + <%= password_input f, :password, required: true %> <%= error_tag f, :password %> <%= label f, :first_name %> @@ -25,11 +27,12 @@ <%= telephone_input f, :phone_number %> <%= error_tag f, :phone_number %> - <%= label f, :courier_status %> - <%= text_input f, :courier_status %> - <%= error_tag f, :courier_status %> - <div> - <%= submit "Save" %> + <%= submit "Register" %> </div> </.form> + +<p> + <%= link "Log in", to: Routes.courier_session_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.courier_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/courier_reset_password/edit.html.heex b/lib/volt_web/templates/courier_reset_password/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..df5c1a15d57073d606f8ac38bee9bc3c88129dcb --- /dev/null +++ b/lib/volt_web/templates/courier_reset_password/edit.html.heex @@ -0,0 +1,26 @@ +<h1>Reset password</h1> + +<.form let={f} for={@changeset} action={Routes.courier_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.courier_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.courier_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/courier_reset_password/new.html.heex b/lib/volt_web/templates/courier_reset_password/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..192986c9e40dae96c30abf25d4f6612be2c51f94 --- /dev/null +++ b/lib/volt_web/templates/courier_reset_password/new.html.heex @@ -0,0 +1,15 @@ +<h1>Forgot your password?</h1> + +<.form let={f} for={:courier} action={Routes.courier_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.courier_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.courier_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/courier_session/new.html.heex b/lib/volt_web/templates/courier_session/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..e3bbcc2f386aa4ae65e36587c95f43a06b646f14 --- /dev/null +++ b/lib/volt_web/templates/courier_session/new.html.heex @@ -0,0 +1,27 @@ +<h1>Log in</h1> + +<.form let={f} for={@conn} action={Routes.courier_session_path(@conn, :create)} as={:courier}> + <%= 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.courier_registration_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.courier_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/courier_settings/edit.html.heex b/lib/volt_web/templates/courier_settings/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..a802e274a3f8edc6057f91e2ac4ebbec688fd187 --- /dev/null +++ b/lib/volt_web/templates/courier_settings/edit.html.heex @@ -0,0 +1,85 @@ +<h1>Settings</h1> + +<h3>Change email</h3> + +<.form let={f} for={@email_changeset} action={Routes.courier_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.courier_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> + +<h3>Change Profile</h3> + +<.form let={f} for={@profile_changeset} action={Routes.courier_settings_path(@conn, :update)} id="update_profile"> + <%= if @profile_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_profile" %> + + <%= label f, :first_name %> + <%= text_input f, :first_name %> + <%= error_tag f, :first_name %> + + <%= label f, :last_name %> + <%= text_input f, :last_name %> + <%= error_tag f, :last_name %> + + <%= label f, :phone_number %> + <%= text_input f, :phone_number %> + <%= error_tag f, :phone_number %> + + <%= label f, :current_password, for: "current_password_for_password" %> + <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_profile" %> + <%= error_tag f, :current_password %> + + <div> + <%= submit "Change profile" %> + </div> +</.form> diff --git a/lib/volt_web/templates/customer/edit.html.heex b/lib/volt_web/templates/customer/edit.html.heex deleted file mode 100644 index 9f9b57d9402c4375ffd85c1c3b73d8daf0c57c85..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/customer/edit.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<h1>Edit Customer</h1> - -<%= render "form.html", Map.put(assigns, :action, Routes.customer_path(@conn, :update, @customer)) %> - -<span><%= link "Back", to: Routes.customer_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/customer/form.html.heex b/lib/volt_web/templates/customer/form.html.heex deleted file mode 100644 index c6a7141f257762610938dbbcfdd1ca8a2397e1df..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/customer/form.html.heex +++ /dev/null @@ -1,51 +0,0 @@ -<.form let={f} for={@changeset} action={@action}> - <%= 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 %> - <%= text_input f, :email, id: "email" %> - <%= error_tag f, :email %> - - <%= label f, :password %> - <%= text_input f, :password, id: "password" %> - <%= error_tag f, :password %> - - <%= label f, :first_name %> - <%= text_input f, :first_name, id: "first_name" %> - <%= error_tag f, :first_name %> - - <%= label f, :last_name %> - <%= text_input f, :last_name, id: "last_name" %> - <%= error_tag f, :last_name %> - - <%= label f, :phone_number %> - <%= text_input f, :phone_number, id: "phone_number" %> - <%= error_tag f, :phone_number %> - - <%= label f, :birth_date %> - <%= date_select f, :birth_date, id: "birth_date" %> - <%= error_tag f, :birth_date %> - - <%= label f, :address %> - <%= text_input f, :address, id: "address" %> - <%= error_tag f, :address %> - - <%= label f, :zip_code %> - <%= text_input f, :zip_code, id: "zip_code" %> - <%= error_tag f, :zip_code %> - - <%= label f, :city %> - <%= text_input f, :city, id: "city" %> - <%= error_tag f, :city %> - - <%= label f, :card_number %> - <%= text_input f, :card_number, id: "card_number" %> - <%= error_tag f, :card_number %> - - <div> - <%= submit "Save", id: "submit_button" %> - </div> -</.form> diff --git a/lib/volt_web/templates/customer/index.html.heex b/lib/volt_web/templates/customer/index.html.heex deleted file mode 100644 index c5ee19b37caf10a48b37b5a7f933ec9747d0d3f5..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/customer/index.html.heex +++ /dev/null @@ -1,46 +0,0 @@ -<h1>Listing Customers</h1> - -<table> - <thead> - <tr> - <th>Email</th> - <th>Password</th> - <th>First name</th> - <th>Last name</th> - <th>Phone number</th> - <th>Birth date</th> - <th>Address</th> - <th>Zip code</th> - <th>City</th> - <th>Card number</th> - <th>Balance</th> - - <th></th> - </tr> - </thead> - <tbody> -<%= for customer <- @customers do %> - <tr> - <td><%= customer.email %></td> - <td><%= customer.password %></td> - <td><%= customer.first_name %></td> - <td><%= customer.last_name %></td> - <td><%= customer.phone_number %></td> - <td><%= customer.birth_date %></td> - <td><%= customer.address %></td> - <td><%= customer.zip_code %></td> - <td><%= customer.city %></td> - <td><%= customer.card_number %></td> - <td><%= customer.balance %></td> - - <td> - <span><%= link "Show", to: Routes.customer_path(@conn, :show, customer) %></span> - <span><%= link "Edit", to: Routes.customer_path(@conn, :edit, customer) %></span> - <span><%= link "Delete", to: Routes.customer_path(@conn, :delete, customer), method: :delete, data: [confirm: "Are you sure?"] %></span> - </td> - </tr> -<% end %> - </tbody> -</table> - -<span><%= link "New Customer", to: Routes.customer_path(@conn, :new) %></span> diff --git a/lib/volt_web/templates/customer/new.html.heex b/lib/volt_web/templates/customer/new.html.heex deleted file mode 100644 index 11e7d93d0e02961212f70cf127682c813ba33796..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/customer/new.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<h1>New Customer</h1> - -<%= render "form.html", Map.put(assigns, :action, Routes.customer_path(@conn, :create)) %> - -<span><%= link "Back", to: Routes.page_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/customer/show.html.heex b/lib/volt_web/templates/customer/show.html.heex deleted file mode 100644 index 139580f9540c7df0cc65af1e3a3eac533ac7ea2e..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/customer/show.html.heex +++ /dev/null @@ -1,63 +0,0 @@ -<h1>Show Customer</h1> - -<ul> - - <li> - <strong>Email:</strong> - <%= @customer.email %> - </li> - - <li> - <strong>Password:</strong> - <%= @customer.password %> - </li> - - <li> - <strong>First name:</strong> - <%= @customer.first_name %> - </li> - - <li> - <strong>Last name:</strong> - <%= @customer.last_name %> - </li> - - <li> - <strong>Phone number:</strong> - <%= @customer.phone_number %> - </li> - - <li> - <strong>Birth date:</strong> - <%= @customer.birth_date %> - </li> - - <li> - <strong>Address:</strong> - <%= @customer.address %> - </li> - - <li> - <strong>Zip code:</strong> - <%= @customer.zip_code %> - </li> - - <li> - <strong>City:</strong> - <%= @customer.city %> - </li> - - <li> - <strong>Card number:</strong> - <%= @customer.card_number %> - </li> - - <li> - <strong>Balance:</strong> - <%= @customer.balance %> - </li> - -</ul> - -<span><%= link "Edit", to: Routes.customer_path(@conn, :edit, @customer) %></span> | -<span><%= link "Back", to: Routes.customer_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/customer_confirmation/edit.html.heex b/lib/volt_web/templates/customer_confirmation/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..5dfef6ee934c504010b6d5fb9dcc3c1911432802 --- /dev/null +++ b/lib/volt_web/templates/customer_confirmation/edit.html.heex @@ -0,0 +1,12 @@ +<h1>Confirm account</h1> + +<.form let={_f} for={:customer} action={Routes.customer_confirmation_path(@conn, :update, @token)}> + <div> + <%= submit "Confirm my account" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.customer_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.customer_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/customer_confirmation/new.html.heex b/lib/volt_web/templates/customer_confirmation/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..9a945375bb5b7dbc7aba3203a3b9028fb5b113ec --- /dev/null +++ b/lib/volt_web/templates/customer_confirmation/new.html.heex @@ -0,0 +1,15 @@ +<h1>Resend confirmation instructions</h1> + +<.form let={f} for={:customer} action={Routes.customer_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.customer_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.customer_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/customer_dashboard/index.html.heex b/lib/volt_web/templates/customer_dashboard/index.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..6dc106af73187637bf8c217304df2775f5d263f6 --- /dev/null +++ b/lib/volt_web/templates/customer_dashboard/index.html.heex @@ -0,0 +1,26 @@ +<h1>Listing restaurants</h1> + +<table> + <thead> + <tr> + <th>Name</th> + <th>Tags</th> + <th>Price Level</th> + <th>Distance</th> + <th></th> + </tr> + </thead> + <tbody> +<%= for restaurant <- @restaurants do %> + <tr> + <td><%= restaurant.name %></td> + <td> </td> + <td><%= restaurant.price_level %></td> + <td><%= restaurant.dist %></td> + <td> + <span><%= link "Order", to: "" %></span> + </td> + </tr> +<% end %> + </tbody> +</table> diff --git a/lib/volt_web/templates/customer_registration/new.html.heex b/lib/volt_web/templates/customer_registration/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..19a9b98cc1ab806491d6a85ebf4f297d81d09daa --- /dev/null +++ b/lib/volt_web/templates/customer_registration/new.html.heex @@ -0,0 +1,142 @@ +<h1>Register</h1> + +<.form let={f} for={@changeset} action={Routes.customer_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, id: "email" %> + <%= error_tag f, :email %> + + <%= label f, :password %> + <%= password_input f, :password, required: true, id: "password" %> + <%= error_tag f, :password %> + + <%= label f, :first_name %> + <%= text_input f, :first_name, id: "first_name" %> + <%= error_tag f, :first_name %> + + <%= label f, :last_name %> + <%= text_input f, :last_name, id: "last_name" %> + <%= error_tag f, :last_name %> + + <%= label f, :phone_number %> + <%= text_input f, :phone_number, id: "phone_number" %> + <%= error_tag f, :phone_number %> + + <%= label f, :birth_date %> + <%= date_select f, :birth_date, id: "birth_date" %> + <%= error_tag f, :birth_date %> + + <div title="current location will be taken from center of the map"> + + <meta charset="utf-8" /> + <script type='text/javascript'> + var map, searchManager; + + function useCurrentLocation() { + // Get the checkbox + var checkBox = document.getElementById("use_current_location_check"); + + if (checkBox.checked == true){ + reverseGeocode() + } else { + document.getElementById("address").value = ""; + document.getElementById("city").value = ""; + document.getElementById("zip_code").value = ""; + } + } + + function GetMap() { + map = new Microsoft.Maps.Map('#myMap', {}); + + //Load the spatial math module + Microsoft.Maps.loadModule("Microsoft.Maps.SpatialMath", function () { + //Request the user's location + navigator.geolocation.getCurrentPosition(function (position) { + var loc = new Microsoft.Maps.Location(position.coords.latitude, position.coords.longitude); + + //Create an accuracy circle + var path = Microsoft.Maps.SpatialMath.getRegularPolygon(loc, position.coords.accuracy, 36, Microsoft.Maps.SpatialMath.Meters); + var poly = new Microsoft.Maps.Polygon(path); + map.entities.push(poly); + + //Add a pushpin at the user's location. + var pin = new Microsoft.Maps.Pushpin(loc); + map.entities.push(pin); + + //Center the map on the user's location. + map.setView({ center: loc, zoom: 17 }); + }); + }); + + //Make a request to reverse geocode the center of the map. + reverseGeocode(); + } + + function reverseGeocode() { + //If search manager is not defined, load the search module. + if (!searchManager) { + //Create an instance of the search manager and call the reverseGeocode function again. + Microsoft.Maps.loadModule('Microsoft.Maps.Search', function () { + searchManager = new Microsoft.Maps.Search.SearchManager(map); + }); + } else { + var searchRequest = { + location: map.getCenter(), + callback: function (r) { + document.getElementById("address").value = r.address.addressLine; + document.getElementById("city").value = r.address.locality; + document.getElementById("zip_code").value = r.address.postalCode; + }, + errorCallback: function (e) { + //If there is an error, alert the user about it. + alert("Unable to reverse geocode location."); + } + }; + + //Make the reverse geocode request. + searchManager.reverseGeocode(searchRequest); + } + } + </script> + + <!--- TODO remove hardcoded key ---> + <script type='text/javascript' src='http://www.bing.com/api/maps/mapcontrol?callback=GetMap&key=Ar7cmpEAx_LoIKFwOuz15AOa6RobbU1CMbgdmPUUJGklvkbKQy1Dz8MwRohxv7kz' async defer></script> + + <body> + <div id="myMap" style="position:relative;width:600px;height:400px;"></div> + </body> + + Use my current location: <input type="checkbox" id="use_current_location_check" onclick="useCurrentLocation()"> + </div> + + <%= label f, :address %> + <%= text_input f, :address, id: "address" %> + <%= error_tag f, :address %> + + <%= label f, :city %> + <%= text_input f, :city, id: "city" %> + <%= error_tag f, :city %> + + <%= label f, :zip_code %> + <%= text_input f, :zip_code, id: "zip_code" %> + <%= error_tag f, :zip_code %> + + <%= label f, :card_number %> + <%= text_input f, :card_number, id: "card_number" %> + <%= error_tag f, :card_number %> + + + <div> + <%= submit "Register", id: "submit_button" %> + </div> +</.form> + +<p> + <%= link "Log in", to: Routes.customer_session_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.customer_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/customer_reset_password/edit.html.heex b/lib/volt_web/templates/customer_reset_password/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..0f09010879ae84974cd3120bbb43e44538bc6e80 --- /dev/null +++ b/lib/volt_web/templates/customer_reset_password/edit.html.heex @@ -0,0 +1,26 @@ +<h1>Reset password</h1> + +<.form let={f} for={@changeset} action={Routes.customer_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.customer_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.customer_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/customer_reset_password/new.html.heex b/lib/volt_web/templates/customer_reset_password/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..0fff4da9b29a52f2b8d0bb89770cd3f50cfb867a --- /dev/null +++ b/lib/volt_web/templates/customer_reset_password/new.html.heex @@ -0,0 +1,15 @@ +<h1>Forgot your password?</h1> + +<.form let={f} for={:customer} action={Routes.customer_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.customer_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.customer_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/customer_session/new.html.heex b/lib/volt_web/templates/customer_session/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..aeb7c91ceb96f63234530ffe15b4bf1bf8a5141a --- /dev/null +++ b/lib/volt_web/templates/customer_session/new.html.heex @@ -0,0 +1,27 @@ +<h1>Log in</h1> + +<.form let={f} for={@conn} action={Routes.customer_session_path(@conn, :create)} as={:customer}> + <%= 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.customer_registration_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.customer_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/customer_settings/edit.html.heex b/lib/volt_web/templates/customer_settings/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..9e9ff0748480f091e3ab809ccc03d2b4a9ff233a --- /dev/null +++ b/lib/volt_web/templates/customer_settings/edit.html.heex @@ -0,0 +1,105 @@ +<h1>Settings</h1> + +<h3>Change email</h3> + +<.form let={f} for={@email_changeset} action={Routes.customer_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.customer_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> + +<h3>Change Profile</h3> + +<.form let={f} for={@profile_changeset} action={Routes.customer_settings_path(@conn, :update)} id="update_profile"> + <%= if @profile_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_profile" %> + + <%= label f, :first_name %> + <%= text_input f, :first_name, id: "first_name" %> + <%= error_tag f, :first_name %> + + <%= label f, :last_name %> + <%= text_input f, :last_name, id: "last_name" %> + <%= error_tag f, :last_name %> + + <%= label f, :phone_number %> + <%= text_input f, :phone_number, id: "phone_number" %> + <%= error_tag f, :phone_number %> + + <%= label f, :birth_date %> + <%= date_select f, :birth_date, id: "birth_date" %> + <%= error_tag f, :birth_date %> + + <%= label f, :address %> + <%= text_input f, :address, id: "address" %> + <%= error_tag f, :address %> + + <%= label f, :zip_code %> + <%= text_input f, :zip_code, id: "zip_code" %> + <%= error_tag f, :zip_code %> + + <%= label f, :city %> + <%= text_input f, :city, id: "city" %> + <%= error_tag f, :city %> + + <%= label f, :card_number %> + <%= text_input f, :card_number, id: "card_number" %> + <%= error_tag f, :card_number %> + + <%= 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 profile" %> + </div> +</.form> diff --git a/lib/volt_web/templates/layout/_courier_menu.html.heex b/lib/volt_web/templates/layout/_courier_menu.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..a4ae19798a0c467da06838b684564ff0f285570e --- /dev/null +++ b/lib/volt_web/templates/layout/_courier_menu.html.heex @@ -0,0 +1,8 @@ +<ul> +<%= if @current_courier do %> + <h3>Greetings Courier!</h3> + <li><%= @current_courier.email %></li> + <li><%= link "Settings", to: Routes.courier_settings_path(@conn, :edit) %></li> + <li><%= link "Log out", to: Routes.courier_session_path(@conn, :delete), method: :delete %></li> +<% end %> +</ul> diff --git a/lib/volt_web/templates/layout/_customer_menu.html.heex b/lib/volt_web/templates/layout/_customer_menu.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..0818f7a6aead2ef2e9d9387443828cf953ac3a21 --- /dev/null +++ b/lib/volt_web/templates/layout/_customer_menu.html.heex @@ -0,0 +1,8 @@ +<ul> +<%= if @current_customer do %> + <h3>Greetings Customer!</h3> + <li><%= @current_customer.email %></li> + <li><%= link "Settings", to: Routes.customer_settings_path(@conn, :edit) %></li> + <li><%= link "Log out", to: Routes.customer_session_path(@conn, :delete), method: :delete %></li> +<% end %> +</ul> diff --git a/lib/volt_web/templates/layout/_restaurant_menu.html.heex b/lib/volt_web/templates/layout/_restaurant_menu.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..e257b7a75cb0e0b802893ca0ef0cda5b1631e2a4 --- /dev/null +++ b/lib/volt_web/templates/layout/_restaurant_menu.html.heex @@ -0,0 +1,8 @@ +<ul> +<%= if @current_restaurant do %> + <h3>Greetings Restaurant Worker!</h3> + <li><%= @current_restaurant.email %></li> + <li><%= link "Settings", to: Routes.restaurant_settings_path(@conn, :edit) %></li> + <li><%= link "Log out", to: Routes.restaurant_session_path(@conn, :delete), method: :delete %></li> +<% end %> +</ul> diff --git a/lib/volt_web/templates/layout/root.html.heex b/lib/volt_web/templates/layout/root.html.heex index ad6be79717d3f12c78ec3730ad01bd92c84b9a7f..978eb8f395f9f24371e10644fb66dd25379da426 100644 --- a/lib/volt_web/templates/layout/root.html.heex +++ b/lib/volt_web/templates/layout/root.html.heex @@ -12,9 +12,29 @@ <body> <header> <section class="container"> + <%= if @current_customer do %> + <a href={Routes.customer_dashboard_path(@conn, :index)} class="phx-logo"> + <img src={Routes.static_path(@conn, "/images/volt_wide_2.png")} alt="Volt logo"/> + </a> + <% end %> + <%= if @current_courier do %> + <a href={Routes.courier_dashboard_path(@conn, :index)} class="phx-logo"> + <img src={Routes.static_path(@conn, "/images/volt_wide_2.png")} alt="Volt logo"/> + </a> + <% end %> + <%= if @current_restaurant do %> + <a href={Routes.restaurant_dashboard_path(@conn, :index)} class="phx-logo"> + <img src={Routes.static_path(@conn, "/images/volt_wide_2.png")} alt="Volt logo"/> + </a> + <% end %> + <%= if !@current_customer and !@current_courier and !@current_restaurant do %> <a href={Routes.page_path(@conn, :index)} class="phx-logo"> <img src={Routes.static_path(@conn, "/images/volt_wide_2.png")} alt="Volt logo"/> </a> + <% end %> + <%= render "_customer_menu.html", assigns %> + <%= render "_restaurant_menu.html", assigns %> + <%= render "_courier_menu.html", assigns %> </section> </header> <%= @inner_content %> diff --git a/lib/volt_web/templates/page/index.html.heex b/lib/volt_web/templates/page/index.html.heex index 2f04ff4503a7c0280b9ce4bd0fbc2291404c6224..7d19891b3375897e29ac7042d14601fb38245671 100644 --- a/lib/volt_web/templates/page/index.html.heex +++ b/lib/volt_web/templates/page/index.html.heex @@ -1,20 +1,32 @@ -<section class="row"> - <article class="column"> - <h2>Self-registration</h2> - <ul> - <li> - <%= link "Customer", to: Routes.customer_path(@conn, :new), id: "customer_register" %> - </li> - <li> - <%= link "Courier", to: Routes.courier_path(@conn, :new) %> - </li> - <li> - <%= link "Restaurant", to: Routes.restaurant_path(@conn, :new) %> - </li> - </ul> - </article> - <article class="column"> - <h2>Login</h2> - - </article> -</section> +<%= if !@current_customer and !@current_courier and !@current_restaurant do %> + <section class="row"> + <article class="column"> + <h2>Self-registration</h2> + <ul> + <li> + <%= link "Customer", to: Routes.customer_registration_path(@conn, :new), id: "customer_register" %> + </li> + <li> + <%= link "Courier", to: Routes.courier_registration_path(@conn, :new) %> + </li> + <li> + <%= link "Restaurant", to: Routes.restaurant_registration_path(@conn, :new) %> + </li> + </ul> + </article> + <article class="column"> + <h2>Login</h2> + <ul> + <li> + <%= link "Customer", to: Routes.customer_session_path(@conn, :new) %> + </li> + <li> + <%= link "Courier", to: Routes.courier_session_path(@conn, :new) %> + </li> + <li> + <%= link "Restaurant", to: Routes.restaurant_session_path(@conn, :new) %> + </li> + </ul> + </article> + </section> +<% end %> diff --git a/lib/volt_web/templates/restaurant/edit.html.heex b/lib/volt_web/templates/restaurant/edit.html.heex deleted file mode 100644 index 0e1fb863fd1c89a4e871cfdd7f23067e35407e75..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/restaurant/edit.html.heex +++ /dev/null @@ -1,14 +0,0 @@ -<h1>Edit Restaurant</h1> - -<%= render "form.html", Map.put(assigns, :action, Routes.restaurant_path(@conn, :update, @restaurant)) %> - - -<%= if @restaurant.id do %> -<span> -<%= link "Back", to: Routes.restaurant_path(@conn, :show, @restaurant.id) %> -</span> -<% else %> -<span> -<%= link "Back", to: Routes.page_path(@conn, :index) %> -</span> -<% end %> diff --git a/lib/volt_web/templates/restaurant/index.html.heex b/lib/volt_web/templates/restaurant/index.html.heex deleted file mode 100644 index ff22216e66aa68611beed0b4ec6e398cfc825153..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/restaurant/index.html.heex +++ /dev/null @@ -1,36 +0,0 @@ -<h1>Restaurant Dashboard</h1> -<ul> - - <li> - <strong>Email:</strong> - <%= @restaurant.email %> - </li> - <li> - <strong>Phone number:</strong> - <%= @restaurant.phone_number %> - </li> - - <li> - <strong>Name:</strong> - <%= @restaurant.name %> - </li> - - <li> - <strong>Address:</strong> - <%= @restaurant.address %> - </li> - - <li> - <strong>Opening time:</strong> - <%= @restaurant.opening_time %> - </li> - - <li> - <strong>Closing time:</strong> - <%= @restaurant.closing_time %> - </li> - -</ul> - -<span><%= link "Edit", to: Routes.restaurant_path(@conn, :edit, @restaurant) %></span> | -<span><%= link "Log out", to: Routes.page_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/restaurant/new.html.heex b/lib/volt_web/templates/restaurant/new.html.heex deleted file mode 100644 index 82ecb216e2369017be7e50f61e6ef99c5176320f..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/restaurant/new.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<h1>New Restaurant</h1> - -<%= render "form.html", Map.put(assigns, :action, Routes.restaurant_path(@conn, :create)) %> - -<span><%= link "Back", to: Routes.page_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/restaurant/show.html.heex b/lib/volt_web/templates/restaurant/show.html.heex deleted file mode 100644 index 51b69d5c9780082127c6b046b28432c047fd8c45..0000000000000000000000000000000000000000 --- a/lib/volt_web/templates/restaurant/show.html.heex +++ /dev/null @@ -1,67 +0,0 @@ -<h1>Show Restaurant</h1> - -<ul> - - <li> - <strong>Email:</strong> - <%= @restaurant.email %> - </li> - - <li> - <strong>Password:</strong> - <%= @restaurant.password %> - </li> - - <li> - <strong>First name:</strong> - <%= @restaurant.first_name %> - </li> - - <li> - <strong>Last name:</strong> - <%= @restaurant.last_name %> - </li> - - <li> - <strong>Phone number:</strong> - <%= @restaurant.phone_number %> - </li> - - <li> - <strong>Name:</strong> - <%= @restaurant.name %> - </li> - - <li> - <strong>Address:</strong> - <%= @restaurant.address %> - </li> - - <li> - <strong>City:</strong> - <%= @restaurant.city %> - </li> - - <li> - <strong>ZIP Code:</strong> - <%= @restaurant.zip_code %> - </li> - - <li> - <strong>Price level:</strong> - <%= @restaurant.price_level %> - </li> - - <li> - <strong>Opening time:</strong> - <%= @restaurant.opening_time %> - </li> - - <li> - <strong>Closing time:</strong> - <%= @restaurant.closing_time %> - </li> - -</ul> - -<span><%= link "Login", to: Routes.page_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/restaurant_confirmation/edit.html.heex b/lib/volt_web/templates/restaurant_confirmation/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..0ed6a092d68e85000e907d6f366c6b0542ae236e --- /dev/null +++ b/lib/volt_web/templates/restaurant_confirmation/edit.html.heex @@ -0,0 +1,12 @@ +<h1>Confirm account</h1> + +<.form let={_f} for={:restaurant} action={Routes.restaurant_confirmation_path(@conn, :update, @token)}> + <div> + <%= submit "Confirm my account" %> + </div> +</.form> + +<p> + <%= link "Register", to: Routes.restaurant_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.restaurant_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/restaurant_confirmation/new.html.heex b/lib/volt_web/templates/restaurant_confirmation/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..d706fa5d308ec6a1c38fcddd1bfa8233c192731a --- /dev/null +++ b/lib/volt_web/templates/restaurant_confirmation/new.html.heex @@ -0,0 +1,15 @@ +<h1>Resend confirmation instructions</h1> + +<.form let={f} for={:restaurant} action={Routes.restaurant_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.restaurant_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.restaurant_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/restaurant_dashboard/index.html.heex b/lib/volt_web/templates/restaurant_dashboard/index.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..d75876d53f404c54e1359c863b4ff6340bf2f538 --- /dev/null +++ b/lib/volt_web/templates/restaurant_dashboard/index.html.heex @@ -0,0 +1,3 @@ +<h1>Restaurant Dashboard</h1> +<span><%= link "Menu", to: Routes.restaurant_item_path(@conn, :index) %></span><br> +<span><%= link "New Item", to: Routes.restaurant_item_path(@conn, :new) %></span> diff --git a/lib/volt_web/templates/restaurant_item/edit.html.heex b/lib/volt_web/templates/restaurant_item/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..9100ce5b614c7643f4a280373609cab47a5cc488 --- /dev/null +++ b/lib/volt_web/templates/restaurant_item/edit.html.heex @@ -0,0 +1,5 @@ +<h1>Edit Item</h1> + +<%= render "form.html", Map.put(assigns, :action, Routes.restaurant_item_path(@conn, :update, @item)) %> + +<span><%= link "Back", to: Routes.restaurant_item_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/restaurant_item/form.html.heex b/lib/volt_web/templates/restaurant_item/form.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..4e9cc10f8036d04565aaaf0482bb91e14914263f --- /dev/null +++ b/lib/volt_web/templates/restaurant_item/form.html.heex @@ -0,0 +1,23 @@ +<.form let={f} for={@changeset} action={@action}> + <%= if @changeset.action do %> + <div class="alert alert-danger"> + <p>Oops, something went wrong! Please check the errors below.</p> + </div> + <% end %> + + <%= label f, :name %> + <%= text_input f, :name, id: "item_name" %> + <%= error_tag f, :name %> + + <%= label f, :description %> + <%= text_input f, :description, id: "item_description" %> + <%= error_tag f, :description %> + + <%= label f, :unit_price %> + <%= number_input f, :unit_price, step: "any", id: "item_unit_price" %> + <%= error_tag f, :unit_price %> + + <div> + <%= submit "Save", id: "submit" %> + </div> +</.form> diff --git a/lib/volt_web/templates/restaurant_item/index.html.heex b/lib/volt_web/templates/restaurant_item/index.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..dc04557dbbab35e87ba4cefeccf023668a40b32f --- /dev/null +++ b/lib/volt_web/templates/restaurant_item/index.html.heex @@ -0,0 +1,29 @@ +<h1>Menu</h1> + +<table> + <thead> + <tr> + <th>Name</th> + <th>Description</th> + <th>Unit Price</th> + <th></th> + </tr> + </thead> + <tbody> +<%= for item <- @items do %> + <tr> + <td><%= item.name %></td> + <td><%= item.description %></td> + <td><%= item.unit_price %></td> + <td> + <span><%= link "Show", to: Routes.restaurant_item_path(@conn, :show, item) %></span> + <span><%= link "Edit", to: Routes.restaurant_item_path(@conn, :edit, item) %></span> + <span><%= link "Delete", to: Routes.restaurant_item_path(@conn, :delete, item), method: :delete, data: [confirm: "Are you sure?"] %></span> + </td> + </tr> +<% end %> + </tbody> +</table> + +<span><%= link "New Item", to: Routes.restaurant_item_path(@conn, :new) %></span><br> +<span><%= link "Dashboard", to: Routes.restaurant_dashboard_path(@conn, :index) %></span><br> diff --git a/lib/volt_web/templates/restaurant_item/new.html.heex b/lib/volt_web/templates/restaurant_item/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..fcc528bdcdf1d76fea3c9ff83abdf7035e9ddda6 --- /dev/null +++ b/lib/volt_web/templates/restaurant_item/new.html.heex @@ -0,0 +1,5 @@ +<h1>New Item</h1> + +<%= render "form.html", Map.put(assigns, :action, Routes.restaurant_item_path(@conn, :create)) %> + +<span><%= link "Back", to: Routes.restaurant_item_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/restaurant_item/show.html.heex b/lib/volt_web/templates/restaurant_item/show.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..f4cbf9c3c774f2a0ef0e2d3b7e98e262dd97b896 --- /dev/null +++ b/lib/volt_web/templates/restaurant_item/show.html.heex @@ -0,0 +1,19 @@ +<h1>Show Item</h1> + +<ul> + <li> + <strong>Name:</strong> + <%= @item.name %> + </li> + <li> + <strong>Description:</strong> + <%= @item.description %> + </li> + <li> + <strong>Unit Price:</strong> + <%= @item.unit_price %> + </li> +</ul> + +<span><%= link "Edit", to: Routes.restaurant_item_path(@conn, :edit, @item) %></span> | +<span><%= link "Back", to: Routes.restaurant_item_path(@conn, :index) %></span> diff --git a/lib/volt_web/templates/restaurant/form.html.heex b/lib/volt_web/templates/restaurant_registration/new.html.heex similarity index 74% rename from lib/volt_web/templates/restaurant/form.html.heex rename to lib/volt_web/templates/restaurant_registration/new.html.heex index 5e7ffb1e5bb541524c3a62f776920236ad442975..74a001709ac95cb74dbcd1efb209c4419851c410 100644 --- a/lib/volt_web/templates/restaurant/form.html.heex +++ b/lib/volt_web/templates/restaurant_registration/new.html.heex @@ -1,4 +1,6 @@ -<.form let={f} for={@changeset} action={@action}> +<h1>Register</h1> + +<.form let={f} for={@changeset} action={Routes.restaurant_registration_path(@conn, :create)}> <%= if @changeset.action do %> <div class="alert alert-danger"> <p>Oops, something went wrong! Please check the errors below.</p> @@ -6,12 +8,11 @@ <% end %> <%= label f, :email %> - <%= text_input f, :email %> + <%= email_input f, :email, required: true %> <%= error_tag f, :email %> - <%= label f, :password %> - <%= text_input f, :password %> + <%= password_input f, :password, required: true %> <%= error_tag f, :password %> <%= label f, :first_name %> @@ -54,7 +55,12 @@ <%= time_select f, :closing_time %> <%= error_tag f, :closing_time %> - <div id="submit"> - <%= submit "Save" %> + <div> + <%= submit "Register", id: "submit" %> </div> </.form> + +<p> + <%= link "Log in", to: Routes.restaurant_session_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.restaurant_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/restaurant_reset_password/edit.html.heex b/lib/volt_web/templates/restaurant_reset_password/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..3a11e0cb02514da3cf4f0fb50fd80b9695c5a27b --- /dev/null +++ b/lib/volt_web/templates/restaurant_reset_password/edit.html.heex @@ -0,0 +1,26 @@ +<h1>Reset password</h1> + +<.form let={f} for={@changeset} action={Routes.restaurant_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.restaurant_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.restaurant_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/restaurant_reset_password/new.html.heex b/lib/volt_web/templates/restaurant_reset_password/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..cae789e472b10c76c225c2245891a62c45b071b9 --- /dev/null +++ b/lib/volt_web/templates/restaurant_reset_password/new.html.heex @@ -0,0 +1,15 @@ +<h1>Forgot your password?</h1> + +<.form let={f} for={:restaurant} action={Routes.restaurant_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.restaurant_registration_path(@conn, :new) %> | + <%= link "Log in", to: Routes.restaurant_session_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/restaurant_session/new.html.heex b/lib/volt_web/templates/restaurant_session/new.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..b4f624092844ad5429820432f49e5e1efc814f08 --- /dev/null +++ b/lib/volt_web/templates/restaurant_session/new.html.heex @@ -0,0 +1,27 @@ +<h1>Log in</h1> + +<.form let={f} for={@conn} action={Routes.restaurant_session_path(@conn, :create)} as={:restaurant}> + <%= 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.restaurant_registration_path(@conn, :new) %> | + <%= link "Forgot your password?", to: Routes.restaurant_reset_password_path(@conn, :new) %> +</p> diff --git a/lib/volt_web/templates/restaurant_settings/edit.html.heex b/lib/volt_web/templates/restaurant_settings/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..a5cce9c204514e5148e5a7aa76c891bd77d5e22a --- /dev/null +++ b/lib/volt_web/templates/restaurant_settings/edit.html.heex @@ -0,0 +1,113 @@ +<h1>Settings</h1> + +<h3>Change email</h3> + +<.form let={f} for={@email_changeset} action={Routes.restaurant_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.restaurant_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> + +<h3>Change Profile</h3> + +<.form let={f} for={@profile_changeset} action={Routes.restaurant_settings_path(@conn, :update)} id="update_profile"> + <%= if @profile_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_profile" %> + + <%= label f, :first_name %> + <%= text_input f, :first_name %> + <%= error_tag f, :first_name %> + + <%= label f, :last_name %> + <%= text_input f, :last_name %> + <%= error_tag f, :last_name %> + + <%= label f, :phone_number %> + <%= text_input f, :phone_number %> + <%= error_tag f, :phone_number %> + + <%= label f, :name %> + <%= text_input f, :name %> + <%= error_tag f, :name %> + + <%= label f, :address %> + <%= text_input f, :address %> + <%= error_tag f, :address %> + + <%= label f, :city %> + <%= text_input f, :city %> + <%= error_tag f, :city %> + + <%= label f, :zip_code %> + <%= text_input f, :zip_code %> + <%= error_tag f, :zip_code %> + + <%= label f, :price_level %> + <%= number_input f, :price_level %> + <%= error_tag f, :price_level %> + + <%= label f, :opening_time %> + <%= time_select f, :opening_time %> + <%= error_tag f, :opening_time %> + + <%= label f, :closing_time %> + <%= time_select f, :closing_time %> + <%= error_tag f, :closing_time %> + + <%= label f, :current_password, for: "current_password_for_password" %> + <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_profile" %> + <%= error_tag f, :current_password %> + + <div> + <%= submit "Change profile" %> + </div> +</.form> diff --git a/lib/volt_web/views/courier_confirmation_view.ex b/lib/volt_web/views/courier_confirmation_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..b17014ec610ff12656ffa4f87eaf8a3b940c8a8f --- /dev/null +++ b/lib/volt_web/views/courier_confirmation_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CourierConfirmationView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/courier_dashboard_view.ex b/lib/volt_web/views/courier_dashboard_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..e71a906829fcc6af4441ab6276fe0d50983dc4ef --- /dev/null +++ b/lib/volt_web/views/courier_dashboard_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CourierDashboardView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/courier_registration_view.ex b/lib/volt_web/views/courier_registration_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..32b82ff7ce21a79633b3ebeb283f660f301164cb --- /dev/null +++ b/lib/volt_web/views/courier_registration_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CourierRegistrationView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/courier_reset_password_view.ex b/lib/volt_web/views/courier_reset_password_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..4814155372946d2e736f1cb1972d851be2172464 --- /dev/null +++ b/lib/volt_web/views/courier_reset_password_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CourierResetPasswordView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/courier_session_view.ex b/lib/volt_web/views/courier_session_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..25baa4c0d6fb1dfd8e5ffd9df0b719e6dc7da1b1 --- /dev/null +++ b/lib/volt_web/views/courier_session_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CourierSessionView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/courier_settings_view.ex b/lib/volt_web/views/courier_settings_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..69179f6e57b970a94aa2eb16824beef4a0a97824 --- /dev/null +++ b/lib/volt_web/views/courier_settings_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CourierSettingsView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/courier_view.ex b/lib/volt_web/views/courier_view.ex deleted file mode 100644 index 885d90d70d88c93b128f822e27214c969101df93..0000000000000000000000000000000000000000 --- a/lib/volt_web/views/courier_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule VoltWeb.CourierView do - use VoltWeb, :view -end diff --git a/lib/volt_web/views/customer_confirmation_view.ex b/lib/volt_web/views/customer_confirmation_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..6c566c64b0b4f4d4a4f2e1f12973852321ba5d30 --- /dev/null +++ b/lib/volt_web/views/customer_confirmation_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CustomerConfirmationView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/customer_dashboard_view.ex b/lib/volt_web/views/customer_dashboard_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..7f38b67a8a226335c890aa8ca4330beb44fa6465 --- /dev/null +++ b/lib/volt_web/views/customer_dashboard_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CustomerDashboardView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/customer_registration_view.ex b/lib/volt_web/views/customer_registration_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..9b77c1acc756859f5094c8016e0303b998f0ced3 --- /dev/null +++ b/lib/volt_web/views/customer_registration_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CustomerRegistrationView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/customer_reset_password_view.ex b/lib/volt_web/views/customer_reset_password_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..c1bcbb6f1d2835d492a4674f5448cbeb3cbda12e --- /dev/null +++ b/lib/volt_web/views/customer_reset_password_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CustomerResetPasswordView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/customer_session_view.ex b/lib/volt_web/views/customer_session_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..648dd039ca2cae877850cfad48b2bd477c6c19e5 --- /dev/null +++ b/lib/volt_web/views/customer_session_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CustomerSessionView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/customer_settings_view.ex b/lib/volt_web/views/customer_settings_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..234109a38b29d3aa43df0190a34acc837890377f --- /dev/null +++ b/lib/volt_web/views/customer_settings_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.CustomerSettingsView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/customer_view.ex b/lib/volt_web/views/customer_view.ex deleted file mode 100644 index 1ed8d1f1071214a12c5f99104d98639661f8e28a..0000000000000000000000000000000000000000 --- a/lib/volt_web/views/customer_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule VoltWeb.CustomerView do - use VoltWeb, :view -end diff --git a/lib/volt_web/views/restaurant_confirmation_view.ex b/lib/volt_web/views/restaurant_confirmation_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..e3a72cb4bf0f49d89e2a8b2a65e45fd0231e86d7 --- /dev/null +++ b/lib/volt_web/views/restaurant_confirmation_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantConfirmationView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_dashboard_view.ex b/lib/volt_web/views/restaurant_dashboard_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..3fa2a1c5b4e66a31dee2d4e325cae95881b8c5cf --- /dev/null +++ b/lib/volt_web/views/restaurant_dashboard_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantDashboardView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_item_view.ex b/lib/volt_web/views/restaurant_item_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..8fcf5590d0b4d1a168268bf6afb4e3369619bffe --- /dev/null +++ b/lib/volt_web/views/restaurant_item_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantItemView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_registration_view.ex b/lib/volt_web/views/restaurant_registration_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..ac97f6375ba042bf54aba447569029d8be7523ac --- /dev/null +++ b/lib/volt_web/views/restaurant_registration_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantRegistrationView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_reset_password_view.ex b/lib/volt_web/views/restaurant_reset_password_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..b847f76034e0713d6f3d05022047aa7dfb7316dc --- /dev/null +++ b/lib/volt_web/views/restaurant_reset_password_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantResetPasswordView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_session_view.ex b/lib/volt_web/views/restaurant_session_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..cf9f150bcb1cd2d12b487ca67bccfd5de124311f --- /dev/null +++ b/lib/volt_web/views/restaurant_session_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantSessionView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_settings_view.ex b/lib/volt_web/views/restaurant_settings_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..781b54ca7b6be10ba34daed3a0900866f7c5c390 --- /dev/null +++ b/lib/volt_web/views/restaurant_settings_view.ex @@ -0,0 +1,3 @@ +defmodule VoltWeb.RestaurantSettingsView do + use VoltWeb, :view +end diff --git a/lib/volt_web/views/restaurant_view.ex b/lib/volt_web/views/restaurant_view.ex deleted file mode 100644 index 7778df5f7e0f6d24e051a1008d06e4c0608b4e85..0000000000000000000000000000000000000000 --- a/lib/volt_web/views/restaurant_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule VoltWeb.RestaurantView do - use VoltWeb, :view -end diff --git a/mix.exs b/mix.exs index f8f7e61d14262809433aeabea2598edb609ac2e9..3186bd10bfb8aab3b2518abbed12a304666616ce 100644 --- a/mix.exs +++ b/mix.exs @@ -7,7 +7,6 @@ defmodule Volt.MixProject do version: "0.1.0", elixir: "~> 1.12", elixirc_paths: elixirc_paths(Mix.env()), - compilers: [:gettext] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps(), @@ -51,7 +50,10 @@ defmodule Volt.MixProject do {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.18"}, {:jason, "~> 1.2"}, - {:plug_cowboy, "~> 2.5"} + {:plug_cowboy, "~> 2.5"}, + {:httpoison, "~> 1.6"}, + {:poison, "~> 3.1"}, + {:pbkdf2_elixir, "~> 2.0"} ] end diff --git a/mix.lock b/mix.lock index 242d778ee5969d449e572d260271e1052b225b8f..304e58967cdf091636aff3ea004e36252aadfb83 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, @@ -17,12 +18,14 @@ "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hound": {:hex, :hound, "1.1.1", "d3afce4cf0f446331d9d00427e9eb74fa135c296b1d3745d4bbe2096ce259087", [:mix], [{:hackney, "~> 1.5", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8c6342b49f53bb0e5c51d5ecca18a8ce872c44da05a8ce6f828385ebd744fe2a"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "2.0.0", "fa10cf0a61e263e5bdcd6353d96cdd1f4c593fd0f8d734ba8f3e048832fd5f06", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "a9bcb808583e63893d42a843669c0e9cdcc9183053b9984582281a8947393086"}, "phoenix": {:hex, :phoenix, "1.6.14", "57678366dc1d5bad49832a0fc7f12c2830c10d3eacfad681bfe9602cd4445f04", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d48c0da00b3d4cd1aad6055387917491af9f6e1f1e96cedf6c6b7998df9dba26"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, @@ -34,6 +37,7 @@ "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, diff --git a/priv/repo/migrations/20221107205242_create_couriers_auth_tables.exs b/priv/repo/migrations/20221107205242_create_couriers_auth_tables.exs new file mode 100644 index 0000000000000000000000000000000000000000..0e9c688ca884c1ef53972ef846043d12930de78b --- /dev/null +++ b/priv/repo/migrations/20221107205242_create_couriers_auth_tables.exs @@ -0,0 +1,27 @@ +defmodule Volt.Repo.Migrations.CreateCouriersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + alter table(:couriers) do + modify :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :naive_datetime + remove :password + end + + create unique_index(:couriers, [:email]) + + create table(:couriers_tokens) do + add :courier_id, references(:couriers, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + timestamps(updated_at: false) + end + + create index(:couriers_tokens, [:courier_id]) + create unique_index(:couriers_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20221107205540_create_restaurants_auth_tables.exs b/priv/repo/migrations/20221107205540_create_restaurants_auth_tables.exs new file mode 100644 index 0000000000000000000000000000000000000000..cc5612db34e34492dbcb73b7a258b6d08adc325f --- /dev/null +++ b/priv/repo/migrations/20221107205540_create_restaurants_auth_tables.exs @@ -0,0 +1,27 @@ +defmodule Volt.Repo.Migrations.CreateRestaurantsAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + alter table(:restaurants) do + modify :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :naive_datetime + remove :password + end + + create unique_index(:restaurants, [:email]) + + create table(:restaurants_tokens) do + add :restaurant_id, references(:restaurants, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + timestamps(updated_at: false) + end + + create index(:restaurants_tokens, [:restaurant_id]) + create unique_index(:restaurants_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20221107210000_create_customers_auth_tables.exs b/priv/repo/migrations/20221107210000_create_customers_auth_tables.exs new file mode 100644 index 0000000000000000000000000000000000000000..9c79a80421d82b69cad63c8e8e9fc4705059affa --- /dev/null +++ b/priv/repo/migrations/20221107210000_create_customers_auth_tables.exs @@ -0,0 +1,27 @@ +defmodule Volt.Repo.Migrations.CreateCustomersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + alter table(:customers) do + modify :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :naive_datetime + remove :password + end + + create unique_index(:customers, [:email]) + + create table(:customers_tokens) do + add :customer_id, references(:customers, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + timestamps(updated_at: false) + end + + create index(:customers_tokens, [:customer_id]) + create unique_index(:customers_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20221112151133_create_items.exs b/priv/repo/migrations/20221112151133_create_items.exs new file mode 100644 index 0000000000000000000000000000000000000000..a9194383a012200b06b1208e7ad00fc3b3752c60 --- /dev/null +++ b/priv/repo/migrations/20221112151133_create_items.exs @@ -0,0 +1,14 @@ +defmodule Volt.Repo.Migrations.CreateItems do + use Ecto.Migration + + def change do + create table(:items) do + add :name, :string + add :description, :string + add :unit_price, :string + add :restaurant_id, references(:restaurants) + + timestamps() + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 3fdd98c1570763a0486248d3a293c390a2d736c8..477412ff432f26635d64de88131cd1fc712552ff 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,82 @@ defmodule VoltWeb.ConnCase do Volt.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in couriers. + + setup :register_and_log_in_courier + + It stores an updated connection and a registered courier in the + test context. + """ + def register_and_log_in_courier(%{conn: conn}) do + courier = Volt.AccountsFixtures.courier_fixture() + %{conn: log_in_courier(conn, courier), courier: courier} + end + + @doc """ + Logs the given `courier` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_courier(conn, courier) do + token = Volt.Accounts.generate_courier_session_token(courier) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:courier_token, token) + end + + @doc """ + Setup helper that registers and logs in restaurants. + + setup :register_and_log_in_restaurant + + It stores an updated connection and a registered restaurant in the + test context. + """ + def register_and_log_in_restaurant(%{conn: conn}) do + restaurant = Volt.AccountsFixtures.restaurant_fixture() + %{conn: log_in_restaurant(conn, restaurant), restaurant: restaurant} + end + + @doc """ + Logs the given `restaurant` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_restaurant(conn, restaurant) do + token = Volt.Accounts.generate_restaurant_session_token(restaurant) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:restaurant_token, token) + end + + @doc """ + Setup helper that registers and logs in customers. + + setup :register_and_log_in_customer + + It stores an updated connection and a registered customer in the + test context. + """ + def register_and_log_in_customer(%{conn: conn}) do + customer = Volt.AccountsFixtures.customer_fixture() + %{conn: log_in_customer(conn, customer), customer: customer} + end + + @doc """ + Logs the given `customer` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_customer(conn, customer) do + token = Volt.Accounts.generate_customer_session_token(customer) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:customer_token, token) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index c1c0ef6634103bf11e6c5ef0f16171008851a5cb..56e4e5ff535ae5939e831c8319c037f0bcafefd8 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -4,68 +4,102 @@ defmodule Volt.AccountsFixtures do entities via the `Volt.Accounts` context. """ - @doc """ - Generate a restaurant. - """ - def restaurant_fixture(attrs \\ %{}) do - {:ok, restaurant} = - attrs - |> Enum.into(%{ - address: "some address", - city: "Tartu", - zip_code: "51004", - closing_time: ~T[14:00:00], - email: "some email", - first_name: "some first_name", - last_name: "some last_name", - name: "some name", - opening_time: ~T[14:00:00], - password: "some password", - phone_number: "some phone_number", - price_level: 42 - }) - |> Volt.Accounts.create_restaurant() - restaurant + def valid_phone_number, do: "+#{Enum.random(1000000000..1000000000000)}" + def unique_courier_email, do: "courier#{System.unique_integer()}@example.com" + def valid_courier_password, do: "hello world!" + + def valid_courier_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_courier_email(), + password: valid_courier_password(), + courier_status: "WAITING", + first_name: "some first_name", + last_name: "some last_name", + phone_number: valid_phone_number() + }) end - @doc """ - Generate a courier. - """ def courier_fixture(attrs \\ %{}) do - {:ok, courier} = + {:ok, user} = attrs - |> Enum.into(%{ - courier_status: "WAITING", - email: "courier@email.com", - first_name: "some first_name", - last_name: "some last_name", - password: "some password", - phone_number: "+37258937485" - }) - |> Volt.Accounts.create_courier() + |> valid_courier_attributes() + |> Volt.Accounts.register_courier() - courier + user + end + + def extract_courier_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end + + def unique_restaurant_email, do: "restaurant#{System.unique_integer()}@example.com" + def valid_restaurant_password, do: "hello world!" + + def valid_restaurant_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + address: "some address", + closing_time: ~T[14:00:00], + email: unique_restaurant_email(), + first_name: "some first_name", + last_name: "some last_name", + name: "some name", + opening_time: ~T[14:00:00], + password: valid_restaurant_password(), + phone_number: valid_phone_number(), + city: "some city", + zip_code: "55555", + price_level: 42 + }) + end + + def restaurant_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_restaurant_attributes() + |> Volt.Accounts.register_restaurant() + + user + end + + def extract_restaurant_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end + + def unique_customer_email, do: "customer#{System.unique_integer()}@example.com" + def valid_customer_password, do: "hello world!" + + def valid_customer_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + address: "some address", + balance: 120.5, + birth_date: ~D[2022-11-04], + card_number: "123123123123123", + email: unique_customer_email(), + password: valid_customer_password(), + first_name: "some first_name", + last_name: "some last_name", + phone_number: valid_phone_number(), + city: "some city", + zip_code: "55555" + }) end - @doc """ - Generate a customer. - """ def customer_fixture(attrs \\ %{}) do - {:ok, customer} = + {:ok, user} = attrs - |> Enum.into(%{ - address: "some address", - balance: 120.5, - birth_date: ~D[2022-11-04], - card_number: "some card_number", - email: "some email", - first_name: "some first_name", - last_name: "some last_name", - password: "some password", - phone_number: "some phone_number" - }) - |> Volt.Accounts.create_customer() + |> valid_customer_attributes() + |> Volt.Accounts.register_customer() + + user + end - customer + def extract_customer_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token end end diff --git a/test/support/fixtures/sales_fixtures.ex b/test/support/fixtures/sales_fixtures.ex new file mode 100644 index 0000000000000000000000000000000000000000..7488957b186449dcc6ac6fa7b08f30d26c19134a --- /dev/null +++ b/test/support/fixtures/sales_fixtures.ex @@ -0,0 +1,22 @@ +defmodule Volt.SalesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Volt.Sales` context. + """ + + @doc """ + Generate a item. + """ + def item_fixture(attrs \\ %{}) do + {:ok, item} = + attrs + |> Enum.into(%{ + description: "some description", + name: "some name", + unit_price: "10.00" + }) + |> Volt.Sales.create_item() + + item + end +end diff --git a/test/volt/accounts_test.exs b/test/volt/accounts_test.exs index 0a0e3a3cfffac4c3e0bfe475b121cf95c8bba3e0..41b40986f53bfc54c22dca8c625d4a5339d89dc1 100644 --- a/test/volt/accounts_test.exs +++ b/test/volt/accounts_test.exs @@ -4,6 +4,1518 @@ defmodule Volt.AccountsTest do alias Volt.Accounts alias Volt.Repo + + import Volt.AccountsFixtures + alias Volt.Accounts.{Courier, CourierToken} + + describe "get_courier_by_email/1" do + test "does not return the courier if the email does not exist" do + refute Accounts.get_courier_by_email("unknown@example.com") + end + + test "returns the courier if the email exists" do + %{id: id} = courier = courier_fixture() + assert %Courier{id: ^id} = Accounts.get_courier_by_email(courier.email) + end + end + + describe "get_courier_by_email_and_password/2" do + test "does not return the courier if the email does not exist" do + refute Accounts.get_courier_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the courier if the password is not valid" do + courier = courier_fixture() + refute Accounts.get_courier_by_email_and_password(courier.email, "invalid") + end + + test "returns the courier if the email and password are valid" do + %{id: id} = courier = courier_fixture() + + assert %Courier{id: ^id} = + Accounts.get_courier_by_email_and_password(courier.email, valid_courier_password()) + end + end + + describe "get_courier!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_courier!(-1) + end + end + + test "returns the courier with the given id" do + %{id: id} = courier = courier_fixture() + assert %Courier{id: ^id} = Accounts.get_courier!(courier.id) + end + end + + describe "register_courier/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_courier(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_courier(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_courier(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = courier_fixture() + {:error, changeset} = Accounts.register_courier(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_courier(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers couriers with a hashed password" do + email = unique_courier_email() + {:ok, courier} = Accounts.register_courier(valid_courier_attributes(email: email)) + assert courier.email == email + assert is_binary(courier.hashed_password) + assert is_nil(courier.confirmed_at) + assert is_nil(courier.password) + end + end + + describe "change_courier_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_courier_registration(%Courier{}) + assert changeset.required == [:password, :email, :first_name, :last_name, :phone_number] + end + + test "allows fields to be set" do + email = unique_courier_email() + password = valid_courier_password() + + changeset = + Accounts.change_courier_registration( + %Courier{}, + valid_courier_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_courier_email/2" do + test "returns a courier changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_courier_email(%Courier{}) + assert changeset.required == [:email] + end + end + + describe "apply_courier_email/3" do + setup do + %{courier: courier_fixture()} + end + + test "requires email to change", %{courier: courier} do + {:error, changeset} = Accounts.apply_courier_email(courier, valid_courier_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{courier: courier} do + {:error, changeset} = + Accounts.apply_courier_email(courier, valid_courier_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{courier: courier} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_courier_email(courier, valid_courier_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{courier: courier} do + %{email: email} = courier_fixture() + + {:error, changeset} = + Accounts.apply_courier_email(courier, valid_courier_password(), %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{courier: courier} do + {:error, changeset} = + Accounts.apply_courier_email(courier, "invalid", %{email: unique_courier_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{courier: courier} do + email = unique_courier_email() + {:ok, courier} = Accounts.apply_courier_email(courier, valid_courier_password(), %{email: email}) + assert courier.email == email + assert Accounts.get_courier!(courier.id).email != email + end + end + + describe "deliver_courier_update_email_instructions/3" do + setup do + %{courier: courier_fixture()} + end + + test "sends token through notification", %{courier: courier} do + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_update_email_instructions(courier, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert courier_token = Repo.get_by(CourierToken, token: :crypto.hash(:sha256, token)) + assert courier_token.courier_id == courier.id + assert courier_token.sent_to == courier.email + assert courier_token.context == "change:current@example.com" + end + end + + describe "update_courier_email/2" do + setup do + courier = courier_fixture() + email = unique_courier_email() + + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_update_email_instructions(%{courier | email: email}, courier.email, url) + end) + + %{courier: courier, token: token, email: email} + end + + test "updates the email with a valid token", %{courier: courier, token: token, email: email} do + assert Accounts.update_courier_email(courier, token) == :ok + changed_courier = Repo.get!(Courier, courier.id) + assert changed_courier.email != courier.email + assert changed_courier.email == email + assert changed_courier.confirmed_at + assert changed_courier.confirmed_at != courier.confirmed_at + refute Repo.get_by(CourierToken, courier_id: courier.id) + end + + test "does not update email with invalid token", %{courier: courier} do + assert Accounts.update_courier_email(courier, "oops") == :error + assert Repo.get!(Courier, courier.id).email == courier.email + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + + test "does not update email if courier email changed", %{courier: courier, token: token} do + assert Accounts.update_courier_email(%{courier | email: "current@example.com"}, token) == :error + assert Repo.get!(Courier, courier.id).email == courier.email + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + + test "does not update email if token expired", %{courier: courier, token: token} do + {1, nil} = Repo.update_all(CourierToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_courier_email(courier, token) == :error + assert Repo.get!(Courier, courier.id).email == courier.email + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + end + + describe "change_courier_password/2" do + test "returns a courier changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_courier_password(%Courier{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_courier_password(%Courier{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_courier_password/3" do + setup do + %{courier: courier_fixture()} + end + + test "validates password", %{courier: courier} do + {:error, changeset} = + Accounts.update_courier_password(courier, valid_courier_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{courier: courier} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_courier_password(courier, valid_courier_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{courier: courier} do + {:error, changeset} = + Accounts.update_courier_password(courier, "invalid", %{password: valid_courier_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{courier: courier} do + {:ok, courier} = + Accounts.update_courier_password(courier, valid_courier_password(), %{ + password: "new valid password" + }) + + assert is_nil(courier.password) + assert Accounts.get_courier_by_email_and_password(courier.email, "new valid password") + end + + test "deletes all tokens for the given courier", %{courier: courier} do + _ = Accounts.generate_courier_session_token(courier) + + {:ok, _} = + Accounts.update_courier_password(courier, valid_courier_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(CourierToken, courier_id: courier.id) + end + end + + describe "generate_courier_session_token/1" do + setup do + %{courier: courier_fixture()} + end + + test "generates a token", %{courier: courier} do + token = Accounts.generate_courier_session_token(courier) + assert courier_token = Repo.get_by(CourierToken, token: token) + assert courier_token.context == "session" + + # Creating the same token for another courier should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%CourierToken{ + token: courier_token.token, + courier_id: courier_fixture().id, + context: "session" + }) + end + end + end + + describe "get_courier_by_session_token/1" do + setup do + courier = courier_fixture() + token = Accounts.generate_courier_session_token(courier) + %{courier: courier, token: token} + end + + test "returns courier by token", %{courier: courier, token: token} do + assert session_courier = Accounts.get_courier_by_session_token(token) + assert session_courier.id == courier.id + end + + test "does not return courier for invalid token" do + refute Accounts.get_courier_by_session_token("oops") + end + + test "does not return courier for expired token", %{token: token} do + {1, nil} = Repo.update_all(CourierToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_courier_by_session_token(token) + end + end + + describe "delete_courier_session_token/1" do + test "deletes the token" do + courier = courier_fixture() + token = Accounts.generate_courier_session_token(courier) + assert Accounts.delete_courier_session_token(token) == :ok + refute Accounts.get_courier_by_session_token(token) + end + end + + describe "deliver_courier_confirmation_instructions/2" do + setup do + %{courier: courier_fixture()} + end + + test "sends token through notification", %{courier: courier} do + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_confirmation_instructions(courier, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert courier_token = Repo.get_by(CourierToken, token: :crypto.hash(:sha256, token)) + assert courier_token.courier_id == courier.id + assert courier_token.sent_to == courier.email + assert courier_token.context == "confirm" + end + end + + describe "confirm_courier/1" do + setup do + courier = courier_fixture() + + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_confirmation_instructions(courier, url) + end) + + %{courier: courier, token: token} + end + + test "confirms the email with a valid token", %{courier: courier, token: token} do + assert {:ok, confirmed_courier} = Accounts.confirm_courier(token) + assert confirmed_courier.confirmed_at + assert confirmed_courier.confirmed_at != courier.confirmed_at + assert Repo.get!(Courier, courier.id).confirmed_at + refute Repo.get_by(CourierToken, courier_id: courier.id) + end + + test "does not confirm with invalid token", %{courier: courier} do + assert Accounts.confirm_courier("oops") == :error + refute Repo.get!(Courier, courier.id).confirmed_at + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + + test "does not confirm email if token expired", %{courier: courier, token: token} do + {1, nil} = Repo.update_all(CourierToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_courier(token) == :error + refute Repo.get!(Courier, courier.id).confirmed_at + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + end + + describe "deliver_courier_reset_password_instructions/2" do + setup do + %{courier: courier_fixture()} + end + + test "sends token through notification", %{courier: courier} do + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_reset_password_instructions(courier, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert courier_token = Repo.get_by(CourierToken, token: :crypto.hash(:sha256, token)) + assert courier_token.courier_id == courier.id + assert courier_token.sent_to == courier.email + assert courier_token.context == "reset_password" + end + end + + describe "get_courier_by_reset_password_token/1" do + setup do + courier = courier_fixture() + + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_reset_password_instructions(courier, url) + end) + + %{courier: courier, token: token} + end + + test "returns the courier with valid token", %{courier: %{id: id}, token: token} do + assert %Courier{id: ^id} = Accounts.get_courier_by_reset_password_token(token) + assert Repo.get_by(CourierToken, courier_id: id) + end + + test "does not return the courier with invalid token", %{courier: courier} do + refute Accounts.get_courier_by_reset_password_token("oops") + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + + test "does not return the courier if token expired", %{courier: courier, token: token} do + {1, nil} = Repo.update_all(CourierToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_courier_by_reset_password_token(token) + assert Repo.get_by(CourierToken, courier_id: courier.id) + end + end + + describe "reset_courier_password/2" do + setup do + %{courier: courier_fixture()} + end + + test "validates password", %{courier: courier} do + {:error, changeset} = + Accounts.reset_courier_password(courier, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{courier: courier} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_courier_password(courier, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{courier: courier} do + {:ok, updated_courier} = Accounts.reset_courier_password(courier, %{password: "new valid password"}) + assert is_nil(updated_courier.password) + assert Accounts.get_courier_by_email_and_password(courier.email, "new valid password") + end + + test "deletes all tokens for the given courier", %{courier: courier} do + _ = Accounts.generate_courier_session_token(courier) + {:ok, _} = Accounts.reset_courier_password(courier, %{password: "new valid password"}) + refute Repo.get_by(CourierToken, courier_id: courier.id) + end + end + + describe "courier_inspect/2" do + test "does not include password" do + refute inspect(%Courier{password: "123456"}) =~ "password: \"123456\"" + end + end + + import Volt.AccountsFixtures + alias Volt.Accounts.{Restaurant, RestaurantToken} + + describe "get_restaurant_by_email/1" do + test "does not return the restaurant if the email does not exist" do + refute Accounts.get_restaurant_by_email("unknown@example.com") + end + + test "returns the restaurant if the email exists" do + %{id: id} = restaurant = restaurant_fixture() + assert %Restaurant{id: ^id} = Accounts.get_restaurant_by_email(restaurant.email) + end + end + + describe "get_restaurant_by_email_and_password/2" do + test "does not return the restaurant if the email does not exist" do + refute Accounts.get_restaurant_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the restaurant if the password is not valid" do + restaurant = restaurant_fixture() + refute Accounts.get_restaurant_by_email_and_password(restaurant.email, "invalid") + end + + test "returns the restaurant if the email and password are valid" do + %{id: id} = restaurant = restaurant_fixture() + + assert %Restaurant{id: ^id} = + Accounts.get_restaurant_by_email_and_password(restaurant.email, valid_restaurant_password()) + end + end + + describe "get_restaurant!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_restaurant!(-1) + end + end + + test "returns the restaurant with the given id" do + %{id: id} = restaurant = restaurant_fixture() + assert %Restaurant{id: ^id} = Accounts.get_restaurant!(restaurant.id) + end + end + + describe "register_restaurant/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_restaurant(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_restaurant(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_restaurant(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = restaurant_fixture() + {:error, changeset} = Accounts.register_restaurant(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_restaurant(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers restaurants with a hashed password" do + email = unique_restaurant_email() + {:ok, restaurant} = Accounts.register_restaurant(valid_restaurant_attributes(email: email)) + assert restaurant.email == email + assert is_binary(restaurant.hashed_password) + assert is_nil(restaurant.confirmed_at) + assert is_nil(restaurant.password) + end + end + + describe "change_restaurant_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_restaurant_registration(%Restaurant{}) + assert changeset.required == [:password, :email, :first_name, :last_name, :phone_number, :name, :address, :price_level, :opening_time, :closing_time, :zip_code, :city] + end + + test "allows fields to be set" do + email = unique_restaurant_email() + password = valid_restaurant_password() + + changeset = + Accounts.change_restaurant_registration( + %Restaurant{}, + valid_restaurant_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_restaurant_email/2" do + test "returns a restaurant changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_restaurant_email(%Restaurant{}) + assert changeset.required == [:email] + end + end + + describe "apply_restaurant_email/3" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "requires email to change", %{restaurant: restaurant} do + {:error, changeset} = Accounts.apply_restaurant_email(restaurant, valid_restaurant_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{restaurant: restaurant} do + {:error, changeset} = + Accounts.apply_restaurant_email(restaurant, valid_restaurant_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{restaurant: restaurant} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_restaurant_email(restaurant, valid_restaurant_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{restaurant: restaurant} do + %{email: email} = restaurant_fixture() + + {:error, changeset} = + Accounts.apply_restaurant_email(restaurant, valid_restaurant_password(), %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{restaurant: restaurant} do + {:error, changeset} = + Accounts.apply_restaurant_email(restaurant, "invalid", %{email: unique_restaurant_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{restaurant: restaurant} do + email = unique_restaurant_email() + {:ok, restaurant} = Accounts.apply_restaurant_email(restaurant, valid_restaurant_password(), %{email: email}) + assert restaurant.email == email + assert Accounts.get_restaurant!(restaurant.id).email != email + end + end + + describe "deliver_restaurant_update_email_instructions/3" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "sends token through notification", %{restaurant: restaurant} do + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_update_email_instructions(restaurant, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert restaurant_token = Repo.get_by(RestaurantToken, token: :crypto.hash(:sha256, token)) + assert restaurant_token.restaurant_id == restaurant.id + assert restaurant_token.sent_to == restaurant.email + assert restaurant_token.context == "change:current@example.com" + end + end + + describe "update_restaurant_email/2" do + setup do + restaurant = restaurant_fixture() + email = unique_restaurant_email() + + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_update_email_instructions(%{restaurant | email: email}, restaurant.email, url) + end) + + %{restaurant: restaurant, token: token, email: email} + end + + test "updates the email with a valid token", %{restaurant: restaurant, token: token, email: email} do + assert Accounts.update_restaurant_email(restaurant, token) == :ok + changed_restaurant = Repo.get!(Restaurant, restaurant.id) + assert changed_restaurant.email != restaurant.email + assert changed_restaurant.email == email + assert changed_restaurant.confirmed_at + assert changed_restaurant.confirmed_at != restaurant.confirmed_at + refute Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not update email with invalid token", %{restaurant: restaurant} do + assert Accounts.update_restaurant_email(restaurant, "oops") == :error + assert Repo.get!(Restaurant, restaurant.id).email == restaurant.email + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not update email if restaurant email changed", %{restaurant: restaurant, token: token} do + assert Accounts.update_restaurant_email(%{restaurant | email: "current@example.com"}, token) == :error + assert Repo.get!(Restaurant, restaurant.id).email == restaurant.email + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not update email if token expired", %{restaurant: restaurant, token: token} do + {1, nil} = Repo.update_all(RestaurantToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_restaurant_email(restaurant, token) == :error + assert Repo.get!(Restaurant, restaurant.id).email == restaurant.email + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + end + + describe "change_restaurant_password/2" do + test "returns a restaurant changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_restaurant_password(%Restaurant{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_restaurant_password(%Restaurant{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_restaurant_password/3" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "validates password", %{restaurant: restaurant} do + {:error, changeset} = + Accounts.update_restaurant_password(restaurant, valid_restaurant_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{restaurant: restaurant} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_restaurant_password(restaurant, valid_restaurant_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{restaurant: restaurant} do + {:error, changeset} = + Accounts.update_restaurant_password(restaurant, "invalid", %{password: valid_restaurant_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{restaurant: restaurant} do + {:ok, restaurant} = + Accounts.update_restaurant_password(restaurant, valid_restaurant_password(), %{ + password: "new valid password" + }) + + assert is_nil(restaurant.password) + assert Accounts.get_restaurant_by_email_and_password(restaurant.email, "new valid password") + end + + test "deletes all tokens for the given restaurant", %{restaurant: restaurant} do + _ = Accounts.generate_restaurant_session_token(restaurant) + + {:ok, _} = + Accounts.update_restaurant_password(restaurant, valid_restaurant_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + end + + describe "generate_restaurant_session_token/1" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "generates a token", %{restaurant: restaurant} do + token = Accounts.generate_restaurant_session_token(restaurant) + assert restaurant_token = Repo.get_by(RestaurantToken, token: token) + assert restaurant_token.context == "session" + + # Creating the same token for another restaurant should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%RestaurantToken{ + token: restaurant_token.token, + restaurant_id: restaurant_fixture().id, + context: "session" + }) + end + end + end + + describe "get_restaurant_by_session_token/1" do + setup do + restaurant = restaurant_fixture() + token = Accounts.generate_restaurant_session_token(restaurant) + %{restaurant: restaurant, token: token} + end + + test "returns restaurant by token", %{restaurant: restaurant, token: token} do + assert session_restaurant = Accounts.get_restaurant_by_session_token(token) + assert session_restaurant.id == restaurant.id + end + + test "does not return restaurant for invalid token" do + refute Accounts.get_restaurant_by_session_token("oops") + end + + test "does not return restaurant for expired token", %{token: token} do + {1, nil} = Repo.update_all(RestaurantToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_restaurant_by_session_token(token) + end + end + + describe "delete_restaurant_session_token/1" do + test "deletes the token" do + restaurant = restaurant_fixture() + token = Accounts.generate_restaurant_session_token(restaurant) + assert Accounts.delete_restaurant_session_token(token) == :ok + refute Accounts.get_restaurant_by_session_token(token) + end + end + + describe "deliver_restaurant_confirmation_instructions/2" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "sends token through notification", %{restaurant: restaurant} do + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_confirmation_instructions(restaurant, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert restaurant_token = Repo.get_by(RestaurantToken, token: :crypto.hash(:sha256, token)) + assert restaurant_token.restaurant_id == restaurant.id + assert restaurant_token.sent_to == restaurant.email + assert restaurant_token.context == "confirm" + end + end + + describe "confirm_restaurant/1" do + setup do + restaurant = restaurant_fixture() + + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_confirmation_instructions(restaurant, url) + end) + + %{restaurant: restaurant, token: token} + end + + test "confirms the email with a valid token", %{restaurant: restaurant, token: token} do + assert {:ok, confirmed_restaurant} = Accounts.confirm_restaurant(token) + assert confirmed_restaurant.confirmed_at + assert confirmed_restaurant.confirmed_at != restaurant.confirmed_at + assert Repo.get!(Restaurant, restaurant.id).confirmed_at + refute Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not confirm with invalid token", %{restaurant: restaurant} do + assert Accounts.confirm_restaurant("oops") == :error + refute Repo.get!(Restaurant, restaurant.id).confirmed_at + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not confirm email if token expired", %{restaurant: restaurant, token: token} do + {1, nil} = Repo.update_all(RestaurantToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_restaurant(token) == :error + refute Repo.get!(Restaurant, restaurant.id).confirmed_at + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + end + + describe "deliver_restaurant_reset_password_instructions/2" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "sends token through notification", %{restaurant: restaurant} do + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_reset_password_instructions(restaurant, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert restaurant_token = Repo.get_by(RestaurantToken, token: :crypto.hash(:sha256, token)) + assert restaurant_token.restaurant_id == restaurant.id + assert restaurant_token.sent_to == restaurant.email + assert restaurant_token.context == "reset_password" + end + end + + describe "get_restaurant_by_reset_password_token/1" do + setup do + restaurant = restaurant_fixture() + + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_reset_password_instructions(restaurant, url) + end) + + %{restaurant: restaurant, token: token} + end + + test "returns the restaurant with valid token", %{restaurant: %{id: id}, token: token} do + assert %Restaurant{id: ^id} = Accounts.get_restaurant_by_reset_password_token(token) + assert Repo.get_by(RestaurantToken, restaurant_id: id) + end + + test "does not return the restaurant with invalid token", %{restaurant: restaurant} do + refute Accounts.get_restaurant_by_reset_password_token("oops") + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not return the restaurant if token expired", %{restaurant: restaurant, token: token} do + {1, nil} = Repo.update_all(RestaurantToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_restaurant_by_reset_password_token(token) + assert Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + end + + describe "reset_restaurant_password/2" do + setup do + %{restaurant: restaurant_fixture()} + end + + test "validates password", %{restaurant: restaurant} do + {:error, changeset} = + Accounts.reset_restaurant_password(restaurant, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{restaurant: restaurant} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_restaurant_password(restaurant, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{restaurant: restaurant} do + {:ok, updated_restaurant} = Accounts.reset_restaurant_password(restaurant, %{password: "new valid password"}) + assert is_nil(updated_restaurant.password) + assert Accounts.get_restaurant_by_email_and_password(restaurant.email, "new valid password") + end + + test "deletes all tokens for the given restaurant", %{restaurant: restaurant} do + _ = Accounts.generate_restaurant_session_token(restaurant) + {:ok, _} = Accounts.reset_restaurant_password(restaurant, %{password: "new valid password"}) + refute Repo.get_by(RestaurantToken, restaurant_id: restaurant.id) + end + end + + describe "restaurant_inspect/2" do + test "does not include password" do + refute inspect(%Restaurant{password: "123456"}) =~ "password: \"123456\"" + end + end + + import Volt.AccountsFixtures + alias Volt.Accounts.{Customer, CustomerToken} + + describe "get_customer_by_email/1" do + test "does not return the customer if the email does not exist" do + refute Accounts.get_customer_by_email("unknown@example.com") + end + + test "returns the customer if the email exists" do + %{id: id} = customer = customer_fixture() + assert %Customer{id: ^id} = Accounts.get_customer_by_email(customer.email) + end + end + + describe "get_customer_by_email_and_password/2" do + test "does not return the customer if the email does not exist" do + refute Accounts.get_customer_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the customer if the password is not valid" do + customer = customer_fixture() + refute Accounts.get_customer_by_email_and_password(customer.email, "invalid") + end + + test "returns the customer if the email and password are valid" do + %{id: id} = customer = customer_fixture() + + assert %Customer{id: ^id} = + Accounts.get_customer_by_email_and_password(customer.email, valid_customer_password()) + end + end + + describe "get_customer!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_customer!(-1) + end + end + + test "returns the customer with the given id" do + %{id: id} = customer = customer_fixture() + assert %Customer{id: ^id} = Accounts.get_customer!(customer.id) + end + end + + describe "register_customer/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_customer(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_customer(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_customer(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = customer_fixture() + {:error, changeset} = Accounts.register_customer(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_customer(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers customers with a hashed password" do + email = unique_customer_email() + {:ok, customer} = Accounts.register_customer(valid_customer_attributes(email: email)) + assert customer.email == email + assert is_binary(customer.hashed_password) + assert is_nil(customer.confirmed_at) + assert is_nil(customer.password) + end + end + + describe "change_customer_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_customer_registration(%Customer{}) + assert changeset.required == [:password, :email, :first_name, :last_name, :phone_number, :birth_date, :address, :zip_code, :city, :card_number, :balance] + end + + test "allows fields to be set" do + email = unique_customer_email() + password = valid_customer_password() + + changeset = + Accounts.change_customer_registration( + %Customer{}, + valid_customer_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_customer_email/2" do + test "returns a customer changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_customer_email(%Customer{}) + assert changeset.required == [:email] + end + end + + describe "apply_customer_email/3" do + setup do + %{customer: customer_fixture()} + end + + test "requires email to change", %{customer: customer} do + {:error, changeset} = Accounts.apply_customer_email(customer, valid_customer_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{customer: customer} do + {:error, changeset} = + Accounts.apply_customer_email(customer, valid_customer_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{customer: customer} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_customer_email(customer, valid_customer_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{customer: customer} do + %{email: email} = customer_fixture() + + {:error, changeset} = + Accounts.apply_customer_email(customer, valid_customer_password(), %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{customer: customer} do + {:error, changeset} = + Accounts.apply_customer_email(customer, "invalid", %{email: unique_customer_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{customer: customer} do + email = unique_customer_email() + {:ok, customer} = Accounts.apply_customer_email(customer, valid_customer_password(), %{email: email}) + assert customer.email == email + assert Accounts.get_customer!(customer.id).email != email + end + end + + describe "deliver_customer_update_email_instructions/3" do + setup do + %{customer: customer_fixture()} + end + + test "sends token through notification", %{customer: customer} do + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_update_email_instructions(customer, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert customer_token = Repo.get_by(CustomerToken, token: :crypto.hash(:sha256, token)) + assert customer_token.customer_id == customer.id + assert customer_token.sent_to == customer.email + assert customer_token.context == "change:current@example.com" + end + end + + describe "update_customer_email/2" do + setup do + customer = customer_fixture() + email = unique_customer_email() + + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_update_email_instructions(%{customer | email: email}, customer.email, url) + end) + + %{customer: customer, token: token, email: email} + end + + test "updates the email with a valid token", %{customer: customer, token: token, email: email} do + assert Accounts.update_customer_email(customer, token) == :ok + changed_customer = Repo.get!(Customer, customer.id) + assert changed_customer.email != customer.email + assert changed_customer.email == email + assert changed_customer.confirmed_at + assert changed_customer.confirmed_at != customer.confirmed_at + refute Repo.get_by(CustomerToken, customer_id: customer.id) + end + + test "does not update email with invalid token", %{customer: customer} do + assert Accounts.update_customer_email(customer, "oops") == :error + assert Repo.get!(Customer, customer.id).email == customer.email + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + + test "does not update email if customer email changed", %{customer: customer, token: token} do + assert Accounts.update_customer_email(%{customer | email: "current@example.com"}, token) == :error + assert Repo.get!(Customer, customer.id).email == customer.email + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + + test "does not update email if token expired", %{customer: customer, token: token} do + {1, nil} = Repo.update_all(CustomerToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_customer_email(customer, token) == :error + assert Repo.get!(Customer, customer.id).email == customer.email + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + end + + describe "change_customer_password/2" do + test "returns a customer changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_customer_password(%Customer{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_customer_password(%Customer{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_customer_password/3" do + setup do + %{customer: customer_fixture()} + end + + test "validates password", %{customer: customer} do + {:error, changeset} = + Accounts.update_customer_password(customer, valid_customer_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{customer: customer} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_customer_password(customer, valid_customer_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{customer: customer} do + {:error, changeset} = + Accounts.update_customer_password(customer, "invalid", %{password: valid_customer_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{customer: customer} do + {:ok, customer} = + Accounts.update_customer_password(customer, valid_customer_password(), %{ + password: "new valid password" + }) + + assert is_nil(customer.password) + assert Accounts.get_customer_by_email_and_password(customer.email, "new valid password") + end + + test "deletes all tokens for the given customer", %{customer: customer} do + _ = Accounts.generate_customer_session_token(customer) + + {:ok, _} = + Accounts.update_customer_password(customer, valid_customer_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(CustomerToken, customer_id: customer.id) + end + end + + describe "generate_customer_session_token/1" do + setup do + %{customer: customer_fixture()} + end + + test "generates a token", %{customer: customer} do + token = Accounts.generate_customer_session_token(customer) + assert customer_token = Repo.get_by(CustomerToken, token: token) + assert customer_token.context == "session" + + # Creating the same token for another customer should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%CustomerToken{ + token: customer_token.token, + customer_id: customer_fixture().id, + context: "session" + }) + end + end + end + + describe "get_customer_by_session_token/1" do + setup do + customer = customer_fixture() + token = Accounts.generate_customer_session_token(customer) + %{customer: customer, token: token} + end + + test "returns customer by token", %{customer: customer, token: token} do + assert session_customer = Accounts.get_customer_by_session_token(token) + assert session_customer.id == customer.id + end + + test "does not return customer for invalid token" do + refute Accounts.get_customer_by_session_token("oops") + end + + test "does not return customer for expired token", %{token: token} do + {1, nil} = Repo.update_all(CustomerToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_customer_by_session_token(token) + end + end + + describe "delete_customer_session_token/1" do + test "deletes the token" do + customer = customer_fixture() + token = Accounts.generate_customer_session_token(customer) + assert Accounts.delete_customer_session_token(token) == :ok + refute Accounts.get_customer_by_session_token(token) + end + end + + describe "deliver_customer_confirmation_instructions/2" do + setup do + %{customer: customer_fixture()} + end + + test "sends token through notification", %{customer: customer} do + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_confirmation_instructions(customer, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert customer_token = Repo.get_by(CustomerToken, token: :crypto.hash(:sha256, token)) + assert customer_token.customer_id == customer.id + assert customer_token.sent_to == customer.email + assert customer_token.context == "confirm" + end + end + + describe "confirm_customer/1" do + setup do + customer = customer_fixture() + + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_confirmation_instructions(customer, url) + end) + + %{customer: customer, token: token} + end + + test "confirms the email with a valid token", %{customer: customer, token: token} do + assert {:ok, confirmed_customer} = Accounts.confirm_customer(token) + assert confirmed_customer.confirmed_at + assert confirmed_customer.confirmed_at != customer.confirmed_at + assert Repo.get!(Customer, customer.id).confirmed_at + refute Repo.get_by(CustomerToken, customer_id: customer.id) + end + + test "does not confirm with invalid token", %{customer: customer} do + assert Accounts.confirm_customer("oops") == :error + refute Repo.get!(Customer, customer.id).confirmed_at + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + + test "does not confirm email if token expired", %{customer: customer, token: token} do + {1, nil} = Repo.update_all(CustomerToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_customer(token) == :error + refute Repo.get!(Customer, customer.id).confirmed_at + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + end + + describe "deliver_customer_reset_password_instructions/2" do + setup do + %{customer: customer_fixture()} + end + + test "sends token through notification", %{customer: customer} do + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_reset_password_instructions(customer, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert customer_token = Repo.get_by(CustomerToken, token: :crypto.hash(:sha256, token)) + assert customer_token.customer_id == customer.id + assert customer_token.sent_to == customer.email + assert customer_token.context == "reset_password" + end + end + + describe "get_customer_by_reset_password_token/1" do + setup do + customer = customer_fixture() + + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_reset_password_instructions(customer, url) + end) + + %{customer: customer, token: token} + end + + test "returns the customer with valid token", %{customer: %{id: id}, token: token} do + assert %Customer{id: ^id} = Accounts.get_customer_by_reset_password_token(token) + assert Repo.get_by(CustomerToken, customer_id: id) + end + + test "does not return the customer with invalid token", %{customer: customer} do + refute Accounts.get_customer_by_reset_password_token("oops") + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + + test "does not return the customer if token expired", %{customer: customer, token: token} do + {1, nil} = Repo.update_all(CustomerToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_customer_by_reset_password_token(token) + assert Repo.get_by(CustomerToken, customer_id: customer.id) + end + end + + describe "reset_customer_password/2" do + setup do + %{customer: customer_fixture()} + end + + test "validates password", %{customer: customer} do + {:error, changeset} = + Accounts.reset_customer_password(customer, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{customer: customer} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_customer_password(customer, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{customer: customer} do + {:ok, updated_customer} = Accounts.reset_customer_password(customer, %{password: "new valid password"}) + assert is_nil(updated_customer.password) + assert Accounts.get_customer_by_email_and_password(customer.email, "new valid password") + end + + test "deletes all tokens for the given customer", %{customer: customer} do + _ = Accounts.generate_customer_session_token(customer) + {:ok, _} = Accounts.reset_customer_password(customer, %{password: "new valid password"}) + refute Repo.get_by(CustomerToken, customer_id: customer.id) + end + end + + describe "customer_inspect/2" do + test "does not include password" do + refute inspect(%Customer{password: "123456"}) =~ "password: \"123456\"" + end + end + +""" +TODO: Revise old tests describe "restaurants" do alias Volt.Accounts.Restaurant @@ -22,18 +1534,18 @@ defmodule Volt.AccountsTest do end test "create_restaurant/1 with valid data creates a restaurant" do - valid_attrs = %{address: "some address", city: "Tartu", zip_code: "51004", closing_time: ~T[14:00:00], email: "some email", first_name: "some first_name", last_name: "some last_name", name: "some name", opening_time: ~T[14:00:00], password: "some password", phone_number: "some phone_number", price_level: 42} + valid_attrs = %{address: "some address", closing_time: ~T[14:00:00], email: "some email", first_name: "some first_name", last_name: "some last_name", name: "some name", opening_time: ~T[14:00:00], password: "some password", phone_number: "some phone_number", price_level: 42} assert {:ok, %Restaurant{} = restaurant} = Accounts.create_restaurant(valid_attrs) assert restaurant.address == "some address" assert restaurant.closing_time == ~T[14:00:00] - assert restaurant.email == "some email" + assert restaurant.email == "some@mail.com" assert restaurant.first_name == "some first_name" assert restaurant.last_name == "some last_name" - assert restaurant.name == "some name" + assert restaurant.name == "Restaurant Name" assert restaurant.opening_time == ~T[14:00:00] - assert restaurant.password == "some password" - assert restaurant.phone_number == "some phone_number" + assert restaurant.password == "some_password_123" + assert restaurant.phone_number == "+37253584617" assert restaurant.price_level == 42 end @@ -46,16 +1558,16 @@ defmodule Volt.AccountsTest do update_attrs = %{address: "some updated address", closing_time: ~T[15:01:01], email: "some updated email", first_name: "some updated first_name", last_name: "some updated last_name", name: "some updated name", opening_time: ~T[15:01:01], password: "some updated password", phone_number: "some updated phone_number", price_level: 43} assert {:ok, %Restaurant{} = restaurant} = Accounts.update_restaurant(restaurant, update_attrs) - assert restaurant.address == "some updated address" - assert restaurant.closing_time == ~T[15:01:01] - assert restaurant.email == "some updated email" - assert restaurant.first_name == "some updated first_name" - assert restaurant.last_name == "some updated last_name" - assert restaurant.name == "some updated name" - assert restaurant.opening_time == ~T[15:01:01] - assert restaurant.password == "some updated password" - assert restaurant.phone_number == "some updated phone_number" - assert restaurant.price_level == 43 + assert restaurant.address == "updated some address" + assert restaurant.closing_time == ~T[14:00:00] + assert restaurant.email == "some@mail.com" + assert restaurant.first_name == "updated some first_name" + assert restaurant.last_name == "updated some last_name" + assert restaurant.name == "Updated Restaurant Name" + assert restaurant.opening_time == ~T[14:00:00] + assert restaurant.password == "updated_some_password_123" + assert restaurant.phone_number == "+37253584617" + assert restaurant.price_level == 42 end test "update_restaurant/2 with invalid data returns error changeset" do @@ -244,4 +1756,5 @@ defmodule Volt.AccountsTest do assert {:error, %Ecto.Changeset{}} = Accounts.update_customer(customer, update_new_attrs) end end + """ end diff --git a/test/volt/sales_test.exs b/test/volt/sales_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..81474b6242bd1d11c1138217017293069ae4d127 --- /dev/null +++ b/test/volt/sales_test.exs @@ -0,0 +1,63 @@ +defmodule Volt.SalesTest do + use Volt.DataCase + + alias Volt.Sales + + describe "items" do + alias Volt.Sales.Item + + import Volt.SalesFixtures + + @invalid_attrs %{description: nil, name: nil, unit_price: nil} + + test "list_items/0 returns all items" do + item = item_fixture() + assert Sales.list_items() == [item] + end + + test "get_item!/1 returns the item with given id" do + item = item_fixture() + assert Sales.get_item!(item.id) == item + end + + test "create_item/1 with valid data creates a item" do + valid_attrs = %{description: "some description", name: "some name", unit_price: "some unit_price"} + + assert {:ok, %Item{} = item} = Sales.create_item(valid_attrs) + assert item.description == "some description" + assert item.name == "some name" + assert item.unit_price == "some unit_price" + end + + test "create_item/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Sales.create_item(@invalid_attrs) + end + + test "update_item/2 with valid data updates the item" do + item = item_fixture() + update_attrs = %{description: "some updated description", name: "some updated name", unit_price: "some updated unit_price"} + + assert {:ok, %Item{} = item} = Sales.update_item(item, update_attrs) + assert item.description == "some updated description" + assert item.name == "some updated name" + assert item.unit_price == "some updated unit_price" + end + + test "update_item/2 with invalid data returns error changeset" do + item = item_fixture() + assert {:error, %Ecto.Changeset{}} = Sales.update_item(item, @invalid_attrs) + assert item == Sales.get_item!(item.id) + end + + test "delete_item/1 deletes the item" do + item = item_fixture() + assert {:ok, %Item{}} = Sales.delete_item(item) + assert_raise Ecto.NoResultsError, fn -> Sales.get_item!(item.id) end + end + + test "change_item/1 returns a item changeset" do + item = item_fixture() + assert %Ecto.Changeset{} = Sales.change_item(item) + end + end +end diff --git a/test/volt_web/controllers/courier_auth_test.exs b/test/volt_web/controllers/courier_auth_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..4f3c2d688ab8e494bca3484f456cceb653077530 --- /dev/null +++ b/test/volt_web/controllers/courier_auth_test.exs @@ -0,0 +1,170 @@ +defmodule VoltWeb.CourierAuthTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias VoltWeb.CourierAuth + import Volt.AccountsFixtures + + @remember_me_cookie "_volt_web_courier_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, VoltWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{courier: courier_fixture(), conn: conn} + end + + describe "log_in_courier/3" do + test "stores the courier token in the session", %{conn: conn, courier: courier} do + conn = CourierAuth.log_in_courier(conn, courier) + assert token = get_session(conn, :courier_token) + assert get_session(conn, :live_socket_id) == "couriers_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == "/couriers/dashboard" + assert Accounts.get_courier_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, courier: courier} do + conn = conn |> put_session(:to_be_removed, "value") |> CourierAuth.log_in_courier(courier) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, courier: courier} do + conn = conn |> put_session(:courier_return_to, "/hello") |> CourierAuth.log_in_courier(courier) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, courier: courier} do + conn = conn |> fetch_cookies() |> CourierAuth.log_in_courier(courier, %{"remember_me" => "true"}) + assert get_session(conn, :courier_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :courier_token) + assert max_age == 5_184_000 + end + end + + describe "logout_courier/1" do + test "erases session and cookies", %{conn: conn, courier: courier} do + courier_token = Accounts.generate_courier_session_token(courier) + + conn = + conn + |> put_session(:courier_token, courier_token) + |> put_req_cookie(@remember_me_cookie, courier_token) + |> fetch_cookies() + |> CourierAuth.log_out_courier() + + refute get_session(conn, :courier_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + refute Accounts.get_courier_by_session_token(courier_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "couriers_sessions:abcdef-token" + VoltWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> CourierAuth.log_out_courier() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if courier is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> CourierAuth.log_out_courier() + refute get_session(conn, :courier_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + end + end + + describe "fetch_current_courier/2" do + test "authenticates courier from session", %{conn: conn, courier: courier} do + courier_token = Accounts.generate_courier_session_token(courier) + conn = conn |> put_session(:courier_token, courier_token) |> CourierAuth.fetch_current_courier([]) + assert conn.assigns.current_courier.id == courier.id + end + + test "authenticates courier from cookies", %{conn: conn, courier: courier} do + logged_in_conn = + conn |> fetch_cookies() |> CourierAuth.log_in_courier(courier, %{"remember_me" => "true"}) + + courier_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> CourierAuth.fetch_current_courier([]) + + assert get_session(conn, :courier_token) == courier_token + assert conn.assigns.current_courier.id == courier.id + end + + test "does not authenticate if data is missing", %{conn: conn, courier: courier} do + _ = Accounts.generate_courier_session_token(courier) + conn = CourierAuth.fetch_current_courier(conn, []) + refute get_session(conn, :courier_token) + refute conn.assigns.current_courier + end + end + + describe "redirect_if_courier_is_authenticated/2" do + test "redirects if courier is authenticated", %{conn: conn, courier: courier} do + conn = conn |> assign(:current_courier, courier) |> CourierAuth.redirect_if_courier_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == "/couriers/dashboard" + end + + test "does not redirect if courier is not authenticated", %{conn: conn} do + conn = CourierAuth.redirect_if_courier_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_courier/2" do + test "redirects if courier is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> CourierAuth.require_authenticated_courier([]) + assert conn.halted + assert redirected_to(conn) == Routes.courier_session_path(conn, :new) + assert get_flash(conn, :error) == "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> CourierAuth.require_authenticated_courier([]) + + assert halted_conn.halted + assert get_session(halted_conn, :courier_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> CourierAuth.require_authenticated_courier([]) + + assert halted_conn.halted + assert get_session(halted_conn, :courier_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> CourierAuth.require_authenticated_courier([]) + + assert halted_conn.halted + refute get_session(halted_conn, :courier_return_to) + end + + test "does not redirect if courier is authenticated", %{conn: conn, courier: courier} do + conn = conn |> assign(:current_courier, courier) |> CourierAuth.require_authenticated_courier([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/volt_web/controllers/courier_confirmation_controller_test.exs b/test/volt_web/controllers/courier_confirmation_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5ba30fd0cc6ceaace8a5311024f37fbd064dca29 --- /dev/null +++ b/test/volt_web/controllers/courier_confirmation_controller_test.exs @@ -0,0 +1,105 @@ +defmodule VoltWeb.CourierConfirmationControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias Volt.Repo + import Volt.AccountsFixtures + + setup do + %{courier: courier_fixture()} + end + + describe "GET /couriers/confirm" do + test "renders the resend confirmation page", %{conn: conn} do + conn = get(conn, Routes.courier_confirmation_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Resend confirmation instructions</h1>" + end + end + + describe "POST /couriers/confirm" do + @tag :capture_log + test "sends a new confirmation token", %{conn: conn, courier: courier} do + conn = + post(conn, Routes.courier_confirmation_path(conn, :create), %{ + "courier" => %{"email" => courier.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.CourierToken, courier_id: courier.id).context == "confirm" + end + + test "does not send confirmation token if Courier is confirmed", %{conn: conn, courier: courier} do + Repo.update!(Accounts.Courier.confirm_changeset(courier)) + + conn = + post(conn, Routes.courier_confirmation_path(conn, :create), %{ + "courier" => %{"email" => courier.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + refute Repo.get_by(Accounts.CourierToken, courier_id: courier.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.courier_confirmation_path(conn, :create), %{ + "courier" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.CourierToken) == [] + end + end + + describe "GET /couriers/confirm/:token" do + test "renders the confirmation page", %{conn: conn} do + conn = get(conn, Routes.courier_confirmation_path(conn, :edit, "some-token")) + response = html_response(conn, 200) + assert response =~ "<h1>Confirm account</h1>" + + form_action = Routes.courier_confirmation_path(conn, :update, "some-token") + assert response =~ "action=\"#{form_action}\"" + end + end + + describe "POST /couriers/confirm/:token" do + test "confirms the given token once", %{conn: conn, courier: courier} do + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_confirmation_instructions(courier, url) + end) + + conn = post(conn, Routes.courier_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "Courier confirmed successfully" + assert Accounts.get_courier!(courier.id).confirmed_at + refute get_session(conn, :courier_token) + assert Repo.all(Accounts.CourierToken) == [] + + # When not logged in + conn = post(conn, Routes.courier_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Courier confirmation link is invalid or it has expired" + + # When logged in + conn = + build_conn() + |> log_in_courier(courier) + |> post(Routes.courier_confirmation_path(conn, :update, token)) + + assert redirected_to(conn) == "/" + refute get_flash(conn, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, courier: courier} do + conn = post(conn, Routes.courier_confirmation_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Courier confirmation link is invalid or it has expired" + refute Accounts.get_courier!(courier.id).confirmed_at + end + end +end diff --git a/test/volt_web/controllers/courier_controller_test.exs b/test/volt_web/controllers/courier_controller_test.exs deleted file mode 100644 index 8812c2a68ee5b019bc23d01ff362ef1ed53b2b39..0000000000000000000000000000000000000000 --- a/test/volt_web/controllers/courier_controller_test.exs +++ /dev/null @@ -1,64 +0,0 @@ -defmodule VoltWeb.CourierControllerTest do - use VoltWeb.ConnCase - - import Volt.AccountsFixtures - - @create_attrs %{courier_status: "some courier_status", email: "roro@gmail.com", first_name: "some first_name", last_name: "some last_name", password: "somepassword", phone_number: "+33641941064"} - @update_attrs %{courier_status: "some updated courier_status", email: "roddro@gmail.com", first_name: "some updated first_name", last_name: "some updated last_name", password: "some updated password", phone_number: "+33641941164"} - @invalid_attrs %{courier_status: nil, email: nil, first_name: nil, last_name: nil, password: nil, phone_number: nil} - - describe "new courier" do - test "renders form", %{conn: conn} do - conn = get(conn, Routes.courier_path(conn, :new)) - assert html_response(conn, 200) =~ "New Courier" - end - end - - describe "create courier" do - test "redirects to show when data is valid", %{conn: conn} do - conn = post(conn, Routes.courier_path(conn, :create), courier: @create_attrs) - - assert %{id: id} = redirected_params(conn) - assert redirected_to(conn) == Routes.courier_path(conn, :show, id) - - conn = get(conn, Routes.courier_path(conn, :show, id)) - assert html_response(conn, 200) =~ "Show Courier" - end - - test "renders errors when data is invalid", %{conn: conn} do - conn = post(conn, Routes.courier_path(conn, :create), courier: @invalid_attrs) - assert html_response(conn, 200) =~ "New Courier" - end - end - - describe "edit courier" do - setup [:create_courier] - - test "renders form for editing chosen courier", %{conn: conn, courier: courier} do - conn = get(conn, Routes.courier_path(conn, :edit, courier)) - assert html_response(conn, 200) =~ "Edit Courier" - end - end - - describe "update courier" do - setup [:create_courier] - - test "redirects when data is valid", %{conn: conn, courier: courier} do - conn = put(conn, Routes.courier_path(conn, :update, courier), courier: @update_attrs) - assert redirected_to(conn) == Routes.courier_path(conn, :show, courier) - - conn = get(conn, Routes.courier_path(conn, :show, courier)) - assert html_response(conn, 200) =~ "some updated courier_status" - end - - test "renders errors when data is invalid", %{conn: conn, courier: courier} do - conn = put(conn, Routes.courier_path(conn, :update, courier), courier: @invalid_attrs) - assert html_response(conn, 200) =~ "Edit Courier" - end - end - - defp create_courier(_) do - courier = courier_fixture() - %{courier: courier} - end -end diff --git a/test/volt_web/controllers/courier_registration_controller_test.exs b/test/volt_web/controllers/courier_registration_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5b40767dbea6664f8e821a0dcf54533c8f2240a4 --- /dev/null +++ b/test/volt_web/controllers/courier_registration_controller_test.exs @@ -0,0 +1,54 @@ +defmodule VoltWeb.CourierRegistrationControllerTest do + use VoltWeb.ConnCase, async: true + + import Volt.AccountsFixtures + + describe "GET /couriers/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, Routes.courier_registration_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Register</h1>" + assert response =~ "Log in</a>" + assert response =~ "<div>\n<button type=\"submit\">Register</button>" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_courier(courier_fixture()) |> get(Routes.courier_registration_path(conn, :new)) + assert redirected_to(conn) == "/couriers/dashboard" + end + end + + describe "POST /couriers/register" do + @tag :capture_log + test "creates account and logs the courier in", %{conn: conn} do + email = unique_courier_email() + + conn = + post(conn, Routes.courier_registration_path(conn, :create), %{ + "courier" => valid_courier_attributes(email: email) + }) + + assert get_session(conn, :courier_token) + assert redirected_to(conn) == "/couriers/dashboard" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings</a>" + assert response =~ "Log out</a>" + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, Routes.courier_registration_path(conn, :create), %{ + "courier" => %{"email" => "with spaces", "password" => "too short"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Register</h1>" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "should be at least 12 character" + end + end +end diff --git a/test/volt_web/controllers/courier_reset_password_controller_test.exs b/test/volt_web/controllers/courier_reset_password_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..492fcd690b8a0d831ed07c093d9fb8c26f29c330 --- /dev/null +++ b/test/volt_web/controllers/courier_reset_password_controller_test.exs @@ -0,0 +1,113 @@ +defmodule VoltWeb.CourierResetPasswordControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias Volt.Repo + import Volt.AccountsFixtures + + setup do + %{courier: courier_fixture()} + end + + describe "GET /couriers/reset_password" do + test "renders the reset password page", %{conn: conn} do + conn = get(conn, Routes.courier_reset_password_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Forgot your password?</h1>" + end + end + + describe "POST /couriers/reset_password" do + @tag :capture_log + test "sends a new reset password token", %{conn: conn, courier: courier} do + conn = + post(conn, Routes.courier_reset_password_path(conn, :create), %{ + "courier" => %{"email" => courier.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.CourierToken, courier_id: courier.id).context == "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.courier_reset_password_path(conn, :create), %{ + "courier" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.CourierToken) == [] + end + end + + describe "GET /couriers/reset_password/:token" do + setup %{courier: courier} do + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_reset_password_instructions(courier, url) + end) + + %{token: token} + end + + test "renders reset password", %{conn: conn, token: token} do + conn = get(conn, Routes.courier_reset_password_path(conn, :edit, token)) + assert html_response(conn, 200) =~ "<h1>Reset password</h1>" + end + + test "does not render reset password with invalid token", %{conn: conn} do + conn = get(conn, Routes.courier_reset_password_path(conn, :edit, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end + + describe "PUT /couriers/reset_password/:token" do + setup %{courier: courier} do + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_reset_password_instructions(courier, url) + end) + + %{token: token} + end + + test "resets password once", %{conn: conn, courier: courier, token: token} do + conn = + put(conn, Routes.courier_reset_password_path(conn, :update, token), %{ + "courier" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == Routes.courier_session_path(conn, :new) + refute get_session(conn, :courier_token) + assert get_flash(conn, :info) =~ "Password reset successfully" + assert Accounts.get_courier_by_email_and_password(courier.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + conn = + put(conn, Routes.courier_reset_password_path(conn, :update, token), %{ + "courier" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Reset password</h1>" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + end + + test "does not reset password with invalid token", %{conn: conn} do + conn = put(conn, Routes.courier_reset_password_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end +end diff --git a/test/volt_web/controllers/courier_session_controller_test.exs b/test/volt_web/controllers/courier_session_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..000d27c833f9a5c667e8652f1eb45eed49b9967e --- /dev/null +++ b/test/volt_web/controllers/courier_session_controller_test.exs @@ -0,0 +1,98 @@ +defmodule VoltWeb.CourierSessionControllerTest do + use VoltWeb.ConnCase, async: true + + import Volt.AccountsFixtures + + setup do + %{courier: courier_fixture()} + end + + describe "GET /couriers/log_in" do + test "renders log in page", %{conn: conn} do + conn = get(conn, Routes.courier_session_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Log in</h1>" + assert response =~ "Register</a>" + assert response =~ "Forgot your password?</a>" + end + + test "redirects if already logged in", %{conn: conn, courier: courier} do + conn = conn |> log_in_courier(courier) |> get(Routes.courier_session_path(conn, :new)) + assert redirected_to(conn) == "/couriers/dashboard" + end + end + + describe "POST /couriers/log_in" do + test "logs the courier in", %{conn: conn, courier: courier} do + conn = + post(conn, Routes.courier_session_path(conn, :create), %{ + "courier" => %{"email" => courier.email, "password" => valid_courier_password()} + }) + + assert get_session(conn, :courier_token) + assert redirected_to(conn) == "/couriers/dashboard" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ courier.email + assert response =~ "Settings</a>" + assert response =~ "Log out</a>" + end + + test "logs the courier in with remember me", %{conn: conn, courier: courier} do + conn = + post(conn, Routes.courier_session_path(conn, :create), %{ + "courier" => %{ + "email" => courier.email, + "password" => valid_courier_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_volt_web_courier_remember_me"] + assert redirected_to(conn) == "/couriers/dashboard" + end + + test "logs the courier in with return to", %{conn: conn, courier: courier} do + conn = + conn + |> init_test_session(courier_return_to: "/foo/bar") + |> post(Routes.courier_session_path(conn, :create), %{ + "courier" => %{ + "email" => courier.email, + "password" => valid_courier_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + end + + test "emits error message with invalid credentials", %{conn: conn, courier: courier} do + conn = + post(conn, Routes.courier_session_path(conn, :create), %{ + "courier" => %{"email" => courier.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Log in</h1>" + assert response =~ "Invalid email or password" + end + end + + describe "DELETE /couriers/log_out" do + test "logs the courier out", %{conn: conn, courier: courier} do + conn = conn |> log_in_courier(courier) |> delete(Routes.courier_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :courier_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + + test "succeeds even if the courier is not logged in", %{conn: conn} do + conn = delete(conn, Routes.courier_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :courier_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/volt_web/controllers/courier_settings_controller_test.exs b/test/volt_web/controllers/courier_settings_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..e0d2cfa84122a06a45bb311a3379fab7cd7ffd4b --- /dev/null +++ b/test/volt_web/controllers/courier_settings_controller_test.exs @@ -0,0 +1,129 @@ +defmodule VoltWeb.CourierSettingsControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + import Volt.AccountsFixtures + + setup :register_and_log_in_courier + + describe "GET /couriers/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, Routes.courier_settings_path(conn, :edit)) + response = html_response(conn, 200) + assert response =~ "<h1>Settings</h1>" + end + + test "redirects if courier is not logged in" do + conn = build_conn() + conn = get(conn, Routes.courier_settings_path(conn, :edit)) + assert redirected_to(conn) == Routes.courier_session_path(conn, :new) + end + end + + describe "PUT /couriers/settings (change password form)" do + test "updates the courier password and resets tokens", %{conn: conn, courier: courier} do + new_password_conn = + put(conn, Routes.courier_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => valid_courier_password(), + "courier" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == Routes.courier_settings_path(conn, :edit) + assert get_session(new_password_conn, :courier_token) != get_session(conn, :courier_token) + assert get_flash(new_password_conn, :info) =~ "Password updated successfully" + assert Accounts.get_courier_by_email_and_password(courier.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, Routes.courier_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => "invalid", + "courier" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "<h1>Settings</h1>" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + assert response =~ "is not valid" + + assert get_session(old_password_conn, :courier_token) == get_session(conn, :courier_token) + end + end + + describe "PUT /couriers/settings (change email form)" do + @tag :capture_log + test "updates the courier email", %{conn: conn, courier: courier} do + conn = + put(conn, Routes.courier_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => valid_courier_password(), + "courier" => %{"email" => unique_courier_email()} + }) + + assert redirected_to(conn) == Routes.courier_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "A link to confirm your email" + assert Accounts.get_courier_by_email(courier.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, Routes.courier_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => "invalid", + "courier" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Settings</h1>" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "is not valid" + end + end + + describe "GET /couriers/settings/confirm_email/:token" do + setup %{courier: courier} do + email = unique_courier_email() + + token = + extract_courier_token(fn url -> + Accounts.deliver_courier_update_email_instructions(%{courier | email: email}, courier.email, url) + end) + + %{token: token, email: email} + end + + test "updates the courier email once", %{conn: conn, courier: courier, token: token, email: email} do + conn = get(conn, Routes.courier_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.courier_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "Email changed successfully" + refute Accounts.get_courier_by_email(courier.email) + assert Accounts.get_courier_by_email(email) + + conn = get(conn, Routes.courier_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.courier_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, courier: courier} do + conn = get(conn, Routes.courier_settings_path(conn, :confirm_email, "oops")) + assert redirected_to(conn) == Routes.courier_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + assert Accounts.get_courier_by_email(courier.email) + end + + test "redirects if courier is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, Routes.courier_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.courier_session_path(conn, :new) + end + end +end diff --git a/test/volt_web/controllers/customer_auth_test.exs b/test/volt_web/controllers/customer_auth_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..bd61c7e705128e67fb1013284a21aa6c134ebb26 --- /dev/null +++ b/test/volt_web/controllers/customer_auth_test.exs @@ -0,0 +1,170 @@ +defmodule VoltWeb.CustomerAuthTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias VoltWeb.CustomerAuth + import Volt.AccountsFixtures + + @remember_me_cookie "_volt_web_customer_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, VoltWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{customer: customer_fixture(), conn: conn} + end + + describe "log_in_customer/3" do + test "stores the customer token in the session", %{conn: conn, customer: customer} do + conn = CustomerAuth.log_in_customer(conn, customer) + assert token = get_session(conn, :customer_token) + assert get_session(conn, :live_socket_id) == "customers_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == "/customers/dashboard" + assert Accounts.get_customer_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, customer: customer} do + conn = conn |> put_session(:to_be_removed, "value") |> CustomerAuth.log_in_customer(customer) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, customer: customer} do + conn = conn |> put_session(:customer_return_to, "/hello") |> CustomerAuth.log_in_customer(customer) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, customer: customer} do + conn = conn |> fetch_cookies() |> CustomerAuth.log_in_customer(customer, %{"remember_me" => "true"}) + assert get_session(conn, :customer_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :customer_token) + assert max_age == 5_184_000 + end + end + + describe "logout_customer/1" do + test "erases session and cookies", %{conn: conn, customer: customer} do + customer_token = Accounts.generate_customer_session_token(customer) + + conn = + conn + |> put_session(:customer_token, customer_token) + |> put_req_cookie(@remember_me_cookie, customer_token) + |> fetch_cookies() + |> CustomerAuth.log_out_customer() + + refute get_session(conn, :customer_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + refute Accounts.get_customer_by_session_token(customer_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "customers_sessions:abcdef-token" + VoltWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> CustomerAuth.log_out_customer() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if customer is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> CustomerAuth.log_out_customer() + refute get_session(conn, :customer_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + end + end + + describe "fetch_current_customer/2" do + test "authenticates customer from session", %{conn: conn, customer: customer} do + customer_token = Accounts.generate_customer_session_token(customer) + conn = conn |> put_session(:customer_token, customer_token) |> CustomerAuth.fetch_current_customer([]) + assert conn.assigns.current_customer.id == customer.id + end + + test "authenticates customer from cookies", %{conn: conn, customer: customer} do + logged_in_conn = + conn |> fetch_cookies() |> CustomerAuth.log_in_customer(customer, %{"remember_me" => "true"}) + + customer_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> CustomerAuth.fetch_current_customer([]) + + assert get_session(conn, :customer_token) == customer_token + assert conn.assigns.current_customer.id == customer.id + end + + test "does not authenticate if data is missing", %{conn: conn, customer: customer} do + _ = Accounts.generate_customer_session_token(customer) + conn = CustomerAuth.fetch_current_customer(conn, []) + refute get_session(conn, :customer_token) + refute conn.assigns.current_customer + end + end + + describe "redirect_if_customer_is_authenticated/2" do + test "redirects if customer is authenticated", %{conn: conn, customer: customer} do + conn = conn |> assign(:current_customer, customer) |> CustomerAuth.redirect_if_customer_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == "/customers/dashboard" + end + + test "does not redirect if customer is not authenticated", %{conn: conn} do + conn = CustomerAuth.redirect_if_customer_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_customer/2" do + test "redirects if customer is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> CustomerAuth.require_authenticated_customer([]) + assert conn.halted + assert redirected_to(conn) == Routes.customer_session_path(conn, :new) + assert get_flash(conn, :error) == "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> CustomerAuth.require_authenticated_customer([]) + + assert halted_conn.halted + assert get_session(halted_conn, :customer_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> CustomerAuth.require_authenticated_customer([]) + + assert halted_conn.halted + assert get_session(halted_conn, :customer_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> CustomerAuth.require_authenticated_customer([]) + + assert halted_conn.halted + refute get_session(halted_conn, :customer_return_to) + end + + test "does not redirect if customer is authenticated", %{conn: conn, customer: customer} do + conn = conn |> assign(:current_customer, customer) |> CustomerAuth.require_authenticated_customer([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/volt_web/controllers/customer_confirmation_controller_test.exs b/test/volt_web/controllers/customer_confirmation_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9c0ceed693914d059e37045a9e612327783091af --- /dev/null +++ b/test/volt_web/controllers/customer_confirmation_controller_test.exs @@ -0,0 +1,105 @@ +defmodule VoltWeb.CustomerConfirmationControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias Volt.Repo + import Volt.AccountsFixtures + + setup do + %{customer: customer_fixture()} + end + + describe "GET /customers/confirm" do + test "renders the resend confirmation page", %{conn: conn} do + conn = get(conn, Routes.customer_confirmation_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Resend confirmation instructions</h1>" + end + end + + describe "POST /customers/confirm" do + @tag :capture_log + test "sends a new confirmation token", %{conn: conn, customer: customer} do + conn = + post(conn, Routes.customer_confirmation_path(conn, :create), %{ + "customer" => %{"email" => customer.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.CustomerToken, customer_id: customer.id).context == "confirm" + end + + test "does not send confirmation token if Customer is confirmed", %{conn: conn, customer: customer} do + Repo.update!(Accounts.Customer.confirm_changeset(customer)) + + conn = + post(conn, Routes.customer_confirmation_path(conn, :create), %{ + "customer" => %{"email" => customer.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + refute Repo.get_by(Accounts.CustomerToken, customer_id: customer.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.customer_confirmation_path(conn, :create), %{ + "customer" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.CustomerToken) == [] + end + end + + describe "GET /customers/confirm/:token" do + test "renders the confirmation page", %{conn: conn} do + conn = get(conn, Routes.customer_confirmation_path(conn, :edit, "some-token")) + response = html_response(conn, 200) + assert response =~ "<h1>Confirm account</h1>" + + form_action = Routes.customer_confirmation_path(conn, :update, "some-token") + assert response =~ "action=\"#{form_action}\"" + end + end + + describe "POST /customers/confirm/:token" do + test "confirms the given token once", %{conn: conn, customer: customer} do + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_confirmation_instructions(customer, url) + end) + + conn = post(conn, Routes.customer_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "Customer confirmed successfully" + assert Accounts.get_customer!(customer.id).confirmed_at + refute get_session(conn, :customer_token) + assert Repo.all(Accounts.CustomerToken) == [] + + # When not logged in + conn = post(conn, Routes.customer_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Customer confirmation link is invalid or it has expired" + + # When logged in + conn = + build_conn() + |> log_in_customer(customer) + |> post(Routes.customer_confirmation_path(conn, :update, token)) + + assert redirected_to(conn) == "/" + refute get_flash(conn, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, customer: customer} do + conn = post(conn, Routes.customer_confirmation_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Customer confirmation link is invalid or it has expired" + refute Accounts.get_customer!(customer.id).confirmed_at + end + end +end diff --git a/test/volt_web/controllers/customer_controller_test.exs b/test/volt_web/controllers/customer_controller_test.exs deleted file mode 100644 index 777c1c98c1066c93641c41e9a83e29ba1e696918..0000000000000000000000000000000000000000 --- a/test/volt_web/controllers/customer_controller_test.exs +++ /dev/null @@ -1,87 +0,0 @@ -defmodule VoltWeb.CustomerControllerTest do - use VoltWeb.ConnCase - alias Volt.{Repo,Accounts.Customer} - - # Declaration of variables to create customer - # create and update attributes are valid to create a customer - # invalid_attrs should raise a changeset erre while using it to create a customer - @create_attrs %{address: "some address", zip_code: "50000", city: "Tartu", balance: 120.5, birth_date: ~D[2022-11-04], card_number: "1234567812345678", email: "roro@gmail.com", first_name: "some first_name", last_name: "some last_name", password: "some password", phone_number: "+33641941064"} - @update_attrs %{address: "some updated address", zip_code: "60000", city: "Parnu", balance: 456.7, birth_date: ~D[2022-11-05], card_number: "1234567899999999", email: "roro@free.fr", first_name: "some updated first_name", last_name: "some updated last_name", password: "some updated password", phone_number: "+65345678876"} - @invalid_attrs %{address: nil, zip_code: "efz", city: nil, balance: nil, birth_date: nil, card_number: "df567h", email: "hah567s@s", first_name: nil, last_name: nil, password: "12s*", phone_number: "qsd4567"} - - # describe "index" do - # test "lists all customers", %{conn: conn} do - # conn = get(conn, Routes.customer_path(conn, :index)) - # assert html_response(conn, 200) =~ "Listing Customers" - # end - # end - - describe "new customer" do - test "renders form", %{conn: conn} do - conn = get(conn, Routes.customer_path(conn, :new)) - assert html_response(conn, 200) =~ "New Customer" - end - end - - describe "create customer" do - test "redirects to show when data is valid", %{conn: conn} do - conn = post(conn, Routes.customer_path(conn, :create), customer: @create_attrs) - - assert %{id: id} = redirected_params(conn) - assert redirected_to(conn) == Routes.customer_path(conn, :show, id) - - conn = get(conn, Routes.customer_path(conn, :show, id)) - assert html_response(conn, 200) =~ "Show Customer" - end - - test "renders errors when data is invalid", %{conn: conn} do - conn = post(conn, Routes.customer_path(conn, :create), customer: @invalid_attrs) - assert html_response(conn, 200) =~ "New Customer" - end - end - - describe "edit customer" do - setup [:create_customer] - - test "renders form for editing chosen customer", %{conn: conn, customer: customer} do - conn = get(conn, Routes.customer_path(conn, :edit, customer)) - assert html_response(conn, 200) =~ "Edit Customer" - end - end - - describe "update customer" do - setup [:create_customer] - - test "redirects when data is valid", %{conn: conn, customer: customer} do - conn = put(conn, Routes.customer_path(conn, :update, customer), customer: @update_attrs) - assert redirected_to(conn) == Routes.customer_path(conn, :show, customer) - - conn = get(conn, Routes.customer_path(conn, :show, customer)) - assert html_response(conn, 200) =~ "some updated address" - end - - test "renders errors when data is invalid", %{conn: conn, customer: customer} do - conn = put(conn, Routes.customer_path(conn, :update, customer), customer: @invalid_attrs) - assert html_response(conn, 200) =~ "Edit Customer" - end - end - - describe "delete customer" do - setup [:create_customer] - - test "deletes chosen customer", %{conn: conn, customer: customer} do - conn = delete(conn, Routes.customer_path(conn, :delete, customer)) - assert redirected_to(conn) == Routes.customer_path(conn, :index) - - assert_error_sent 404, fn -> - get(conn, Routes.customer_path(conn, :show, customer)) - end - end - end - - defp create_customer(_) do - # {:ok, customer: customer} = Accounts.create_customer(@create_attrs) - customer = Repo.insert!(Map.merge(%Customer{},@create_attrs)) - %{customer: customer} - end -end diff --git a/test/volt_web/controllers/customer_registration_controller_test.exs b/test/volt_web/controllers/customer_registration_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..ea0b397c8d9f3f7339dffe5b69e746844a5328e8 --- /dev/null +++ b/test/volt_web/controllers/customer_registration_controller_test.exs @@ -0,0 +1,61 @@ +defmodule VoltWeb.CustomerRegistrationControllerTest do + use VoltWeb.ConnCase, async: true + + import Volt.AccountsFixtures + + describe "GET /customers/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, Routes.customer_registration_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Register</h1>" + assert response =~ "Log in</a>" + assert response =~ "<div>\n<button id=\"submit_button\" type=\"submit\">Register</button>" + end + + test "renders location map", %{conn: conn} do + conn = get(conn, Routes.customer_registration_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "id=\"myMap\"" + assert response =~ "Microsoft.Map" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_customer(customer_fixture()) |> get(Routes.customer_registration_path(conn, :new)) + assert redirected_to(conn) == "/customers/dashboard" + end + end + + describe "POST /customers/register" do + @tag :capture_log + test "creates account and logs the customer in", %{conn: conn} do + email = unique_customer_email() + + conn = + post(conn, Routes.customer_registration_path(conn, :create), %{ + "customer" => valid_customer_attributes(email: email) + }) + + assert get_session(conn, :customer_token) + assert redirected_to(conn) == "/customers/dashboard" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings</a>" + assert response =~ "Log out</a>" + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, Routes.customer_registration_path(conn, :create), %{ + "customer" => %{"email" => "with spaces", "password" => "too short"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Register</h1>" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "should be at least 12 character" + end + end +end diff --git a/test/volt_web/controllers/customer_reset_password_controller_test.exs b/test/volt_web/controllers/customer_reset_password_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..d4d0059c4d31f0b9adf814315f7e205d99e620db --- /dev/null +++ b/test/volt_web/controllers/customer_reset_password_controller_test.exs @@ -0,0 +1,113 @@ +defmodule VoltWeb.CustomerResetPasswordControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias Volt.Repo + import Volt.AccountsFixtures + + setup do + %{customer: customer_fixture()} + end + + describe "GET /customers/reset_password" do + test "renders the reset password page", %{conn: conn} do + conn = get(conn, Routes.customer_reset_password_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Forgot your password?</h1>" + end + end + + describe "POST /customers/reset_password" do + @tag :capture_log + test "sends a new reset password token", %{conn: conn, customer: customer} do + conn = + post(conn, Routes.customer_reset_password_path(conn, :create), %{ + "customer" => %{"email" => customer.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.CustomerToken, customer_id: customer.id).context == "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.customer_reset_password_path(conn, :create), %{ + "customer" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.CustomerToken) == [] + end + end + + describe "GET /customers/reset_password/:token" do + setup %{customer: customer} do + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_reset_password_instructions(customer, url) + end) + + %{token: token} + end + + test "renders reset password", %{conn: conn, token: token} do + conn = get(conn, Routes.customer_reset_password_path(conn, :edit, token)) + assert html_response(conn, 200) =~ "<h1>Reset password</h1>" + end + + test "does not render reset password with invalid token", %{conn: conn} do + conn = get(conn, Routes.customer_reset_password_path(conn, :edit, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end + + describe "PUT /customers/reset_password/:token" do + setup %{customer: customer} do + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_reset_password_instructions(customer, url) + end) + + %{token: token} + end + + test "resets password once", %{conn: conn, customer: customer, token: token} do + conn = + put(conn, Routes.customer_reset_password_path(conn, :update, token), %{ + "customer" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == Routes.customer_session_path(conn, :new) + refute get_session(conn, :customer_token) + assert get_flash(conn, :info) =~ "Password reset successfully" + assert Accounts.get_customer_by_email_and_password(customer.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + conn = + put(conn, Routes.customer_reset_password_path(conn, :update, token), %{ + "customer" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Reset password</h1>" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + end + + test "does not reset password with invalid token", %{conn: conn} do + conn = put(conn, Routes.customer_reset_password_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end +end diff --git a/test/volt_web/controllers/customer_session_controller_test.exs b/test/volt_web/controllers/customer_session_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..403e5787f9e58e183e3aa90f925f99e65dfe24eb --- /dev/null +++ b/test/volt_web/controllers/customer_session_controller_test.exs @@ -0,0 +1,98 @@ +defmodule VoltWeb.CustomerSessionControllerTest do + use VoltWeb.ConnCase, async: true + + import Volt.AccountsFixtures + + setup do + %{customer: customer_fixture()} + end + + describe "GET /customers/log_in" do + test "renders log in page", %{conn: conn} do + conn = get(conn, Routes.customer_session_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Log in</h1>" + assert response =~ "Register</a>" + assert response =~ "Forgot your password?</a>" + end + + test "redirects if already logged in", %{conn: conn, customer: customer} do + conn = conn |> log_in_customer(customer) |> get(Routes.customer_session_path(conn, :new)) + assert redirected_to(conn) == "/customers/dashboard" + end + end + + describe "POST /customers/log_in" do + test "logs the customer in", %{conn: conn, customer: customer} do + conn = + post(conn, Routes.customer_session_path(conn, :create), %{ + "customer" => %{"email" => customer.email, "password" => valid_customer_password()} + }) + + assert get_session(conn, :customer_token) + assert redirected_to(conn) == "/customers/dashboard" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ customer.email + assert response =~ "Settings</a>" + assert response =~ "Log out</a>" + end + + test "logs the customer in with remember me", %{conn: conn, customer: customer} do + conn = + post(conn, Routes.customer_session_path(conn, :create), %{ + "customer" => %{ + "email" => customer.email, + "password" => valid_customer_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_volt_web_customer_remember_me"] + assert redirected_to(conn) == "/customers/dashboard" + end + + test "logs the customer in with return to", %{conn: conn, customer: customer} do + conn = + conn + |> init_test_session(customer_return_to: "/foo/bar") + |> post(Routes.customer_session_path(conn, :create), %{ + "customer" => %{ + "email" => customer.email, + "password" => valid_customer_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + end + + test "emits error message with invalid credentials", %{conn: conn, customer: customer} do + conn = + post(conn, Routes.customer_session_path(conn, :create), %{ + "customer" => %{"email" => customer.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Log in</h1>" + assert response =~ "Invalid email or password" + end + end + + describe "DELETE /customers/log_out" do + test "logs the customer out", %{conn: conn, customer: customer} do + conn = conn |> log_in_customer(customer) |> delete(Routes.customer_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :customer_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + + test "succeeds even if the customer is not logged in", %{conn: conn} do + conn = delete(conn, Routes.customer_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :customer_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/volt_web/controllers/customer_settings_controller_test.exs b/test/volt_web/controllers/customer_settings_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..39011a5cd646fb72d69aaead7dc3f27c59baec8f --- /dev/null +++ b/test/volt_web/controllers/customer_settings_controller_test.exs @@ -0,0 +1,129 @@ +defmodule VoltWeb.CustomerSettingsControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + import Volt.AccountsFixtures + + setup :register_and_log_in_customer + + describe "GET /customers/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, Routes.customer_settings_path(conn, :edit)) + response = html_response(conn, 200) + assert response =~ "<h1>Settings</h1>" + end + + test "redirects if customer is not logged in" do + conn = build_conn() + conn = get(conn, Routes.customer_settings_path(conn, :edit)) + assert redirected_to(conn) == Routes.customer_session_path(conn, :new) + end + end + + describe "PUT /customers/settings (change password form)" do + test "updates the customer password and resets tokens", %{conn: conn, customer: customer} do + new_password_conn = + put(conn, Routes.customer_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => valid_customer_password(), + "customer" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == Routes.customer_settings_path(conn, :edit) + assert get_session(new_password_conn, :customer_token) != get_session(conn, :customer_token) + assert get_flash(new_password_conn, :info) =~ "Password updated successfully" + assert Accounts.get_customer_by_email_and_password(customer.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, Routes.customer_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => "invalid", + "customer" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "<h1>Settings</h1>" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + assert response =~ "is not valid" + + assert get_session(old_password_conn, :customer_token) == get_session(conn, :customer_token) + end + end + + describe "PUT /customers/settings (change email form)" do + @tag :capture_log + test "updates the customer email", %{conn: conn, customer: customer} do + conn = + put(conn, Routes.customer_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => valid_customer_password(), + "customer" => %{"email" => unique_customer_email()} + }) + + assert redirected_to(conn) == Routes.customer_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "A link to confirm your email" + assert Accounts.get_customer_by_email(customer.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, Routes.customer_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => "invalid", + "customer" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Settings</h1>" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "is not valid" + end + end + + describe "GET /customers/settings/confirm_email/:token" do + setup %{customer: customer} do + email = unique_customer_email() + + token = + extract_customer_token(fn url -> + Accounts.deliver_customer_update_email_instructions(%{customer | email: email}, customer.email, url) + end) + + %{token: token, email: email} + end + + test "updates the customer email once", %{conn: conn, customer: customer, token: token, email: email} do + conn = get(conn, Routes.customer_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.customer_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "Email changed successfully" + refute Accounts.get_customer_by_email(customer.email) + assert Accounts.get_customer_by_email(email) + + conn = get(conn, Routes.customer_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.customer_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, customer: customer} do + conn = get(conn, Routes.customer_settings_path(conn, :confirm_email, "oops")) + assert redirected_to(conn) == Routes.customer_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + assert Accounts.get_customer_by_email(customer.email) + end + + test "redirects if customer is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, Routes.customer_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.customer_session_path(conn, :new) + end + end +end diff --git a/test/volt_web/controllers/restaurant_auth_test.exs b/test/volt_web/controllers/restaurant_auth_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..7ddd3470caf2d370b4c42e2ac9108b04ed52f8dc --- /dev/null +++ b/test/volt_web/controllers/restaurant_auth_test.exs @@ -0,0 +1,170 @@ +defmodule VoltWeb.RestaurantAuthTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias VoltWeb.RestaurantAuth + import Volt.AccountsFixtures + + @remember_me_cookie "_volt_web_restaurant_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, VoltWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{restaurant: restaurant_fixture(), conn: conn} + end + + describe "log_in_restaurant/3" do + test "stores the restaurant token in the session", %{conn: conn, restaurant: restaurant} do + conn = RestaurantAuth.log_in_restaurant(conn, restaurant) + assert token = get_session(conn, :restaurant_token) + assert get_session(conn, :live_socket_id) == "restaurants_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == "/restaurants/dashboard" + assert Accounts.get_restaurant_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, restaurant: restaurant} do + conn = conn |> put_session(:to_be_removed, "value") |> RestaurantAuth.log_in_restaurant(restaurant) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, restaurant: restaurant} do + conn = conn |> put_session(:restaurant_return_to, "/hello") |> RestaurantAuth.log_in_restaurant(restaurant) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, restaurant: restaurant} do + conn = conn |> fetch_cookies() |> RestaurantAuth.log_in_restaurant(restaurant, %{"remember_me" => "true"}) + assert get_session(conn, :restaurant_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :restaurant_token) + assert max_age == 5_184_000 + end + end + + describe "logout_restaurant/1" do + test "erases session and cookies", %{conn: conn, restaurant: restaurant} do + restaurant_token = Accounts.generate_restaurant_session_token(restaurant) + + conn = + conn + |> put_session(:restaurant_token, restaurant_token) + |> put_req_cookie(@remember_me_cookie, restaurant_token) + |> fetch_cookies() + |> RestaurantAuth.log_out_restaurant() + + refute get_session(conn, :restaurant_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + refute Accounts.get_restaurant_by_session_token(restaurant_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "restaurants_sessions:abcdef-token" + VoltWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> RestaurantAuth.log_out_restaurant() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if restaurant is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> RestaurantAuth.log_out_restaurant() + refute get_session(conn, :restaurant_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == "/" + end + end + + describe "fetch_current_restaurant/2" do + test "authenticates restaurant from session", %{conn: conn, restaurant: restaurant} do + restaurant_token = Accounts.generate_restaurant_session_token(restaurant) + conn = conn |> put_session(:restaurant_token, restaurant_token) |> RestaurantAuth.fetch_current_restaurant([]) + assert conn.assigns.current_restaurant.id == restaurant.id + end + + test "authenticates restaurant from cookies", %{conn: conn, restaurant: restaurant} do + logged_in_conn = + conn |> fetch_cookies() |> RestaurantAuth.log_in_restaurant(restaurant, %{"remember_me" => "true"}) + + restaurant_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> RestaurantAuth.fetch_current_restaurant([]) + + assert get_session(conn, :restaurant_token) == restaurant_token + assert conn.assigns.current_restaurant.id == restaurant.id + end + + test "does not authenticate if data is missing", %{conn: conn, restaurant: restaurant} do + _ = Accounts.generate_restaurant_session_token(restaurant) + conn = RestaurantAuth.fetch_current_restaurant(conn, []) + refute get_session(conn, :restaurant_token) + refute conn.assigns.current_restaurant + end + end + + describe "redirect_if_restaurant_is_authenticated/2" do + test "redirects if restaurant is authenticated", %{conn: conn, restaurant: restaurant} do + conn = conn |> assign(:current_restaurant, restaurant) |> RestaurantAuth.redirect_if_restaurant_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == "/restaurants/dashboard" + end + + test "does not redirect if restaurant is not authenticated", %{conn: conn} do + conn = RestaurantAuth.redirect_if_restaurant_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_restaurant/2" do + test "redirects if restaurant is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> RestaurantAuth.require_authenticated_restaurant([]) + assert conn.halted + assert redirected_to(conn) == Routes.restaurant_session_path(conn, :new) + assert get_flash(conn, :error) == "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> RestaurantAuth.require_authenticated_restaurant([]) + + assert halted_conn.halted + assert get_session(halted_conn, :restaurant_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> RestaurantAuth.require_authenticated_restaurant([]) + + assert halted_conn.halted + assert get_session(halted_conn, :restaurant_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> RestaurantAuth.require_authenticated_restaurant([]) + + assert halted_conn.halted + refute get_session(halted_conn, :restaurant_return_to) + end + + test "does not redirect if restaurant is authenticated", %{conn: conn, restaurant: restaurant} do + conn = conn |> assign(:current_restaurant, restaurant) |> RestaurantAuth.require_authenticated_restaurant([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/volt_web/controllers/restaurant_confirmation_controller_test.exs b/test/volt_web/controllers/restaurant_confirmation_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9c22eda3053e695e48e2f7414dd07e0da3a35d72 --- /dev/null +++ b/test/volt_web/controllers/restaurant_confirmation_controller_test.exs @@ -0,0 +1,105 @@ +defmodule VoltWeb.RestaurantConfirmationControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias Volt.Repo + import Volt.AccountsFixtures + + setup do + %{restaurant: restaurant_fixture()} + end + + describe "GET /restaurants/confirm" do + test "renders the resend confirmation page", %{conn: conn} do + conn = get(conn, Routes.restaurant_confirmation_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Resend confirmation instructions</h1>" + end + end + + describe "POST /restaurants/confirm" do + @tag :capture_log + test "sends a new confirmation token", %{conn: conn, restaurant: restaurant} do + conn = + post(conn, Routes.restaurant_confirmation_path(conn, :create), %{ + "restaurant" => %{"email" => restaurant.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.RestaurantToken, restaurant_id: restaurant.id).context == "confirm" + end + + test "does not send confirmation token if Restaurant is confirmed", %{conn: conn, restaurant: restaurant} do + Repo.update!(Accounts.Restaurant.confirm_changeset(restaurant)) + + conn = + post(conn, Routes.restaurant_confirmation_path(conn, :create), %{ + "restaurant" => %{"email" => restaurant.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + refute Repo.get_by(Accounts.RestaurantToken, restaurant_id: restaurant.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.restaurant_confirmation_path(conn, :create), %{ + "restaurant" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.RestaurantToken) == [] + end + end + + describe "GET /restaurants/confirm/:token" do + test "renders the confirmation page", %{conn: conn} do + conn = get(conn, Routes.restaurant_confirmation_path(conn, :edit, "some-token")) + response = html_response(conn, 200) + assert response =~ "<h1>Confirm account</h1>" + + form_action = Routes.restaurant_confirmation_path(conn, :update, "some-token") + assert response =~ "action=\"#{form_action}\"" + end + end + + describe "POST /restaurants/confirm/:token" do + test "confirms the given token once", %{conn: conn, restaurant: restaurant} do + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_confirmation_instructions(restaurant, url) + end) + + conn = post(conn, Routes.restaurant_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "Restaurant confirmed successfully" + assert Accounts.get_restaurant!(restaurant.id).confirmed_at + refute get_session(conn, :restaurant_token) + assert Repo.all(Accounts.RestaurantToken) == [] + + # When not logged in + conn = post(conn, Routes.restaurant_confirmation_path(conn, :update, token)) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Restaurant confirmation link is invalid or it has expired" + + # When logged in + conn = + build_conn() + |> log_in_restaurant(restaurant) + |> post(Routes.restaurant_confirmation_path(conn, :update, token)) + + assert redirected_to(conn) == "/" + refute get_flash(conn, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, restaurant: restaurant} do + conn = post(conn, Routes.restaurant_confirmation_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Restaurant confirmation link is invalid or it has expired" + refute Accounts.get_restaurant!(restaurant.id).confirmed_at + end + end +end diff --git a/test/volt_web/controllers/restaurant_controller_test.exs b/test/volt_web/controllers/restaurant_controller_test.exs deleted file mode 100644 index f14eec8469ae4e93c536d66d974e76c67fd4c2e1..0000000000000000000000000000000000000000 --- a/test/volt_web/controllers/restaurant_controller_test.exs +++ /dev/null @@ -1,91 +0,0 @@ -defmodule VoltWeb.RestaurantControllerTest do - use VoltWeb.ConnCase - - import Volt.AccountsFixtures - - @create_attrs %{address: "some address", city: "Tartu", zip_code: "51004", closing_time: ~T[14:00:00], email: "some email", first_name: "some first_name", last_name: "some last_name", name: "some name", opening_time: ~T[14:00:00], password: "some password", phone_number: "some phone_number", price_level: 42} - @update_attrs %{address: "some updated address", city: "Tartu", zip_code: "51004", closing_time: ~T[15:01:01], email: "some updated email", first_name: "some updated first_name", last_name: "some updated last_name", name: "some updated name", opening_time: ~T[15:01:01], password: "some updated password", phone_number: "some updated phone_number", price_level: 43} - @invalid_attrs %{address: nil, closing_time: nil, city: "Tartu", zip_code: "51004", email: nil, first_name: nil, last_name: nil, name: nil, opening_time: nil, password: nil, phone_number: nil, price_level: nil} - - describe "restaurant dashboard" do - setup [:create_restaurant] - test "renders dashboard", %{conn: conn, restaurant: restaurant} do - id = restaurant.id - conn = get(conn, Routes.restaurant_path(conn, :show, id)) - assert html_response(conn, 200) =~ "Restaurant Dashboard" - assert html_response(conn, 200) =~ "Edit" - end - end - - describe "new restaurant" do - test "renders form", %{conn: conn} do - conn = get(conn, Routes.restaurant_path(conn, :new)) - assert html_response(conn, 200) =~ "New Restaurant" - end - end - - describe "create restaurant" do - test "redirects to show when data is valid", %{conn: conn} do - conn = post(conn, Routes.restaurant_path(conn, :create), restaurant: @create_attrs) - - assert %{id: id} = redirected_params(conn) - assert redirected_to(conn) == Routes.restaurant_path(conn, :show, id) - - conn = get(conn, Routes.restaurant_path(conn, :show, id)) - assert html_response(conn, 200) =~ "Restaurant Dashboard" - end - - test "renders errors when data is invalid", %{conn: conn} do - conn = post(conn, Routes.restaurant_path(conn, :create), restaurant: @invalid_attrs) - assert html_response(conn, 200) =~ "New Restaurant" - end - end - - describe "edit restaurant" do - setup [:create_restaurant] - - test "renders form for editing chosen restaurant", %{conn: conn, restaurant: restaurant} do - conn = get(conn, Routes.restaurant_path(conn, :edit, restaurant)) - assert html_response(conn, 200) =~ "Edit Restaurant" - end - end - - describe "update restaurant" do - setup [:create_restaurant] - - test "redirects when data is valid", %{conn: conn, restaurant: restaurant} do - conn = put(conn, Routes.restaurant_path(conn, :update, restaurant), restaurant: @update_attrs) - id = restaurant.id - assert redirected_to(conn) == Routes.restaurant_path(conn, :show, id) - - conn = get(conn, Routes.restaurant_path(conn, :show, id)) - assert html_response(conn, 200) =~ "some updated address" - end - - test "renders errors when data is invalid", %{conn: conn, restaurant: restaurant} do - conn = put(conn, Routes.restaurant_path(conn, :update, restaurant), restaurant: @invalid_attrs) - assert html_response(conn, 200) =~ "Edit Restaurant" - end - end - - # Deleting a restaurant is not specified in functional documentation. - # Leaving it as a reference for further tests where deletion may be required. - # - # describe "delete restaurant" do - # setup [:create_restaurant] - - # test "deletes chosen restaurant", %{conn: conn, restaurant: restaurant} do - # conn = delete(conn, Routes.restaurant_path(conn, :delete, restaurant)) - # assert redirected_to(conn) == Routes.restaurant_path(conn, :show, restaurant.id) - - # assert_error_sent 404, fn -> - # get(conn, Routes.restaurant_path(conn, :show, restaurant.id)) - # end - # end - # end - - defp create_restaurant(_) do - restaurant = restaurant_fixture() - %{restaurant: restaurant} - end -end diff --git a/test/volt_web/controllers/restaurant_item_controller_test.exs b/test/volt_web/controllers/restaurant_item_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..73779617da0de0fec591be24c2a26436d6728c33 --- /dev/null +++ b/test/volt_web/controllers/restaurant_item_controller_test.exs @@ -0,0 +1,94 @@ +defmodule VoltWeb.ItemControllerTest do + use VoltWeb.ConnCase + + import Volt.SalesFixtures + import Volt.AccountsFixtures + alias VoltWeb.RestaurantAuth + + @create_attrs %{description: "some description", name: "some name", unit_price: "12.99"} + @update_attrs %{description: "some updated description", name: "some updated name", unit_price: "13"} + @invalid_attrs %{description: nil, name: nil, unit_price: nil} + + describe "index" do + test "lists all items", %{conn: conn} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = get(conn, Routes.restaurant_item_path(conn, :index)) + assert html_response(conn, 200) =~ "Menu" + end + end + + describe "new item" do + test "renders form", %{conn: conn} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = get(conn, Routes.restaurant_item_path(conn, :new)) + assert html_response(conn, 200) =~ "New Item" + end + end + + describe "create item" do + test "redirects to show when data is valid", %{conn: conn} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = post(conn, Routes.restaurant_item_path(conn, :create), item: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == Routes.restaurant_item_path(conn, :show, id) + + conn = get(conn, Routes.restaurant_item_path(conn, :show, id)) + assert html_response(conn, 200) =~ "Show Item" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = post(conn, Routes.restaurant_item_path(conn, :create), item: @invalid_attrs) + assert html_response(conn, 200) =~ "New Item" + end + end + + describe "edit item" do + setup [:create_item] + + test "renders form for editing chosen item", %{conn: conn, item: item} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = get(conn, Routes.restaurant_item_path(conn, :edit, item)) + assert html_response(conn, 200) =~ "Edit Item" + end + end + + describe "update item" do + setup [:create_item] + + test "redirects when data is valid", %{conn: conn, item: item} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = put(conn, Routes.restaurant_item_path(conn, :update, item), item: @update_attrs) + assert redirected_to(conn) == Routes.restaurant_item_path(conn, :show, item) + + conn = get(conn, Routes.restaurant_item_path(conn, :show, item)) + assert html_response(conn, 200) =~ "some updated description" + end + + test "renders errors when data is invalid", %{conn: conn, item: item} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = put(conn, Routes.restaurant_item_path(conn, :update, item), item: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Item" + end + end + + describe "delete item" do + setup [:create_item] + + test "deletes chosen item", %{conn: conn, item: item} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + conn = delete(conn, Routes.restaurant_item_path(conn, :delete, item)) + assert redirected_to(conn) == Routes.restaurant_item_path(conn, :index) + + assert_error_sent 500, fn -> + get(conn, Routes.restaurant_item_path(conn, :show, item)) + end + end + end + + defp create_item(_) do + item = item_fixture() + %{item: item} + end +end diff --git a/test/volt_web/controllers/restaurant_registration_controller_test.exs b/test/volt_web/controllers/restaurant_registration_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..cab3a3e988c740087cac6f61622770714b6a6924 --- /dev/null +++ b/test/volt_web/controllers/restaurant_registration_controller_test.exs @@ -0,0 +1,54 @@ +defmodule VoltWeb.RestaurantRegistrationControllerTest do + use VoltWeb.ConnCase, async: true + + import Volt.AccountsFixtures + + describe "GET /restaurants/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, Routes.restaurant_registration_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Register</h1>" + assert response =~ "Log in</a>" + assert response =~ "<div>\n<button id=\"submit\" type=\"submit\">Register</button>" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_restaurant(restaurant_fixture()) |> get(Routes.restaurant_registration_path(conn, :new)) + assert redirected_to(conn) == "/restaurants/dashboard" + end + end + + describe "POST /restaurants/register" do + @tag :capture_log + test "creates account and logs the restaurant in", %{conn: conn} do + email = unique_restaurant_email() + + conn = + post(conn, Routes.restaurant_registration_path(conn, :create), %{ + "restaurant" => valid_restaurant_attributes(email: email) + }) + + assert get_session(conn, :restaurant_token) + assert redirected_to(conn) == "/restaurants/dashboard" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings</a>" + assert response =~ "Log out</a>" + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, Routes.restaurant_registration_path(conn, :create), %{ + "restaurant" => %{"email" => "with spaces", "password" => "too short"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Register</h1>" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "should be at least 12 character" + end + end +end diff --git a/test/volt_web/controllers/restaurant_reset_password_controller_test.exs b/test/volt_web/controllers/restaurant_reset_password_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1e1b99b499c4a97a2fdfc6517013cdef5613bfc6 --- /dev/null +++ b/test/volt_web/controllers/restaurant_reset_password_controller_test.exs @@ -0,0 +1,113 @@ +defmodule VoltWeb.RestaurantResetPasswordControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + alias Volt.Repo + import Volt.AccountsFixtures + + setup do + %{restaurant: restaurant_fixture()} + end + + describe "GET /restaurants/reset_password" do + test "renders the reset password page", %{conn: conn} do + conn = get(conn, Routes.restaurant_reset_password_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Forgot your password?</h1>" + end + end + + describe "POST /restaurants/reset_password" do + @tag :capture_log + test "sends a new reset password token", %{conn: conn, restaurant: restaurant} do + conn = + post(conn, Routes.restaurant_reset_password_path(conn, :create), %{ + "restaurant" => %{"email" => restaurant.email} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.get_by!(Accounts.RestaurantToken, restaurant_id: restaurant.id).context == "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + conn = + post(conn, Routes.restaurant_reset_password_path(conn, :create), %{ + "restaurant" => %{"email" => "unknown@example.com"} + }) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.RestaurantToken) == [] + end + end + + describe "GET /restaurants/reset_password/:token" do + setup %{restaurant: restaurant} do + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_reset_password_instructions(restaurant, url) + end) + + %{token: token} + end + + test "renders reset password", %{conn: conn, token: token} do + conn = get(conn, Routes.restaurant_reset_password_path(conn, :edit, token)) + assert html_response(conn, 200) =~ "<h1>Reset password</h1>" + end + + test "does not render reset password with invalid token", %{conn: conn} do + conn = get(conn, Routes.restaurant_reset_password_path(conn, :edit, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end + + describe "PUT /restaurants/reset_password/:token" do + setup %{restaurant: restaurant} do + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_reset_password_instructions(restaurant, url) + end) + + %{token: token} + end + + test "resets password once", %{conn: conn, restaurant: restaurant, token: token} do + conn = + put(conn, Routes.restaurant_reset_password_path(conn, :update, token), %{ + "restaurant" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(conn) == Routes.restaurant_session_path(conn, :new) + refute get_session(conn, :restaurant_token) + assert get_flash(conn, :info) =~ "Password reset successfully" + assert Accounts.get_restaurant_by_email_and_password(restaurant.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + conn = + put(conn, Routes.restaurant_reset_password_path(conn, :update, token), %{ + "restaurant" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Reset password</h1>" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + end + + test "does not reset password with invalid token", %{conn: conn} do + conn = put(conn, Routes.restaurant_reset_password_path(conn, :update, "oops")) + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" + end + end +end diff --git a/test/volt_web/controllers/restaurant_session_controller_test.exs b/test/volt_web/controllers/restaurant_session_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..a514af4ef9fbda1df241194ccc8b4a01a42e6498 --- /dev/null +++ b/test/volt_web/controllers/restaurant_session_controller_test.exs @@ -0,0 +1,98 @@ +defmodule VoltWeb.RestaurantSessionControllerTest do + use VoltWeb.ConnCase, async: true + + import Volt.AccountsFixtures + + setup do + %{restaurant: restaurant_fixture()} + end + + describe "GET /restaurants/log_in" do + test "renders log in page", %{conn: conn} do + conn = get(conn, Routes.restaurant_session_path(conn, :new)) + response = html_response(conn, 200) + assert response =~ "<h1>Log in</h1>" + assert response =~ "Register</a>" + assert response =~ "Forgot your password?</a>" + end + + test "redirects if already logged in", %{conn: conn, restaurant: restaurant} do + conn = conn |> log_in_restaurant(restaurant) |> get(Routes.restaurant_session_path(conn, :new)) + assert redirected_to(conn) == "/restaurants/dashboard" + end + end + + describe "POST /restaurants/log_in" do + test "logs the restaurant in", %{conn: conn, restaurant: restaurant} do + conn = + post(conn, Routes.restaurant_session_path(conn, :create), %{ + "restaurant" => %{"email" => restaurant.email, "password" => valid_restaurant_password()} + }) + + assert get_session(conn, :restaurant_token) + assert redirected_to(conn) == "/restaurants/dashboard" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ restaurant.email + assert response =~ "Settings</a>" + assert response =~ "Log out</a>" + end + + test "logs the restaurant in with remember me", %{conn: conn, restaurant: restaurant} do + conn = + post(conn, Routes.restaurant_session_path(conn, :create), %{ + "restaurant" => %{ + "email" => restaurant.email, + "password" => valid_restaurant_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_volt_web_restaurant_remember_me"] + assert redirected_to(conn) == "/restaurants/dashboard" + end + + test "logs the restaurant in with return to", %{conn: conn, restaurant: restaurant} do + conn = + conn + |> init_test_session(restaurant_return_to: "/foo/bar") + |> post(Routes.restaurant_session_path(conn, :create), %{ + "restaurant" => %{ + "email" => restaurant.email, + "password" => valid_restaurant_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + end + + test "emits error message with invalid credentials", %{conn: conn, restaurant: restaurant} do + conn = + post(conn, Routes.restaurant_session_path(conn, :create), %{ + "restaurant" => %{"email" => restaurant.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Log in</h1>" + assert response =~ "Invalid email or password" + end + end + + describe "DELETE /restaurants/log_out" do + test "logs the restaurant out", %{conn: conn, restaurant: restaurant} do + conn = conn |> log_in_restaurant(restaurant) |> delete(Routes.restaurant_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :restaurant_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + + test "succeeds even if the restaurant is not logged in", %{conn: conn} do + conn = delete(conn, Routes.restaurant_session_path(conn, :delete)) + assert redirected_to(conn) == "/" + refute get_session(conn, :restaurant_token) + assert get_flash(conn, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/volt_web/controllers/restaurant_settings_controller_test.exs b/test/volt_web/controllers/restaurant_settings_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1199d18812f2156b2c22664436fb7614e1922e15 --- /dev/null +++ b/test/volt_web/controllers/restaurant_settings_controller_test.exs @@ -0,0 +1,129 @@ +defmodule VoltWeb.RestaurantSettingsControllerTest do + use VoltWeb.ConnCase, async: true + + alias Volt.Accounts + import Volt.AccountsFixtures + + setup :register_and_log_in_restaurant + + describe "GET /restaurants/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, Routes.restaurant_settings_path(conn, :edit)) + response = html_response(conn, 200) + assert response =~ "<h1>Settings</h1>" + end + + test "redirects if restaurant is not logged in" do + conn = build_conn() + conn = get(conn, Routes.restaurant_settings_path(conn, :edit)) + assert redirected_to(conn) == Routes.restaurant_session_path(conn, :new) + end + end + + describe "PUT /restaurants/settings (change password form)" do + test "updates the restaurant password and resets tokens", %{conn: conn, restaurant: restaurant} do + new_password_conn = + put(conn, Routes.restaurant_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => valid_restaurant_password(), + "restaurant" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == Routes.restaurant_settings_path(conn, :edit) + assert get_session(new_password_conn, :restaurant_token) != get_session(conn, :restaurant_token) + assert get_flash(new_password_conn, :info) =~ "Password updated successfully" + assert Accounts.get_restaurant_by_email_and_password(restaurant.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, Routes.restaurant_settings_path(conn, :update), %{ + "action" => "update_password", + "current_password" => "invalid", + "restaurant" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "<h1>Settings</h1>" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + assert response =~ "is not valid" + + assert get_session(old_password_conn, :restaurant_token) == get_session(conn, :restaurant_token) + end + end + + describe "PUT /restaurants/settings (change email form)" do + @tag :capture_log + test "updates the restaurant email", %{conn: conn, restaurant: restaurant} do + conn = + put(conn, Routes.restaurant_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => valid_restaurant_password(), + "restaurant" => %{"email" => unique_restaurant_email()} + }) + + assert redirected_to(conn) == Routes.restaurant_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "A link to confirm your email" + assert Accounts.get_restaurant_by_email(restaurant.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, Routes.restaurant_settings_path(conn, :update), %{ + "action" => "update_email", + "current_password" => "invalid", + "restaurant" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "<h1>Settings</h1>" + assert response =~ "must have the @ sign and no spaces" + assert response =~ "is not valid" + end + end + + describe "GET /restaurants/settings/confirm_email/:token" do + setup %{restaurant: restaurant} do + email = unique_restaurant_email() + + token = + extract_restaurant_token(fn url -> + Accounts.deliver_restaurant_update_email_instructions(%{restaurant | email: email}, restaurant.email, url) + end) + + %{token: token, email: email} + end + + test "updates the restaurant email once", %{conn: conn, restaurant: restaurant, token: token, email: email} do + conn = get(conn, Routes.restaurant_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.restaurant_settings_path(conn, :edit) + assert get_flash(conn, :info) =~ "Email changed successfully" + refute Accounts.get_restaurant_by_email(restaurant.email) + assert Accounts.get_restaurant_by_email(email) + + conn = get(conn, Routes.restaurant_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.restaurant_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, restaurant: restaurant} do + conn = get(conn, Routes.restaurant_settings_path(conn, :confirm_email, "oops")) + assert redirected_to(conn) == Routes.restaurant_settings_path(conn, :edit) + assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" + assert Accounts.get_restaurant_by_email(restaurant.email) + end + + test "redirects if restaurant is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, Routes.restaurant_settings_path(conn, :confirm_email, token)) + assert redirected_to(conn) == Routes.restaurant_session_path(conn, :new) + end + end +end