diff --git a/config/test.exs b/config/test.exs index a2eddbd..26d06b8 100644 --- a/config/test.exs +++ b/config/test.exs @@ -50,3 +50,6 @@ config :phoenix, :plug_init_mode, :runtime config :phoenix_live_view, # Enable helpful, but potentially expensive runtime checks enable_expensive_runtime_checks: true + +# test config +config :bcrypt_elixir, log_rounds: 4 diff --git a/lib/atlas/accounts.ex b/lib/atlas/accounts.ex index 38a05f2..325fa17 100644 --- a/lib/atlas/accounts.ex +++ b/lib/atlas/accounts.ex @@ -488,4 +488,30 @@ defmodule Atlas.Accounts do Guardian.DB.revoke_all(user_session.id) Repo.delete(user_session) end + + def get_active_user!(id), do: get_user!(id) + + def update_user_password(user, attrs) do + update_user_password( + user, + Map.get(attrs, "current_password") || Map.get(attrs, :current_password), + attrs + ) + end + + def update_user_profile(user, attrs) do + user + |> User.profile_changeset(attrs) + |> Repo.update() + end + + def delete_user_account(user) do + user + |> Ecto.Changeset.change(is_active: false) + |> Repo.update() + end + + def authenticate_user(email, password) do + get_user_by_email_and_password(email, password) + end end diff --git a/lib/atlas/accounts/user.ex b/lib/atlas/accounts/user.ex index 510bbc2..06a23e8 100644 --- a/lib/atlas/accounts/user.ex +++ b/lib/atlas/accounts/user.ex @@ -12,6 +12,10 @@ defmodule Atlas.Accounts.User do field :current_password, :string, virtual: true, redact: true field :confirmed_at, :utc_datetime field :type, Ecto.Enum, values: [:student, :admin, :professor] + field :is_active, :boolean, default: true + field :gender, :string + field :birth_date, :date + field :profile_picture, :string timestamps(type: :utc_datetime) end @@ -163,4 +167,27 @@ defmodule Atlas.Accounts.User do add_error(changeset, :current_password, "is not valid") end end + + def changeset(user, attrs) do + registration_changeset(user, attrs) + end + + def profile_changeset(user, attrs) do + fields = [:name, :email, :type, :gender, :birth_date] + + fields = + if Map.has_key?(attrs, "profile_picture"), do: fields ++ [:profile_picture], else: fields + + user + |> cast(attrs, fields) + |> validate_required([:name, :email]) + |> validate_inclusion(:gender, ["male", "female", "other"]) + |> validate_change(:birth_date, fn :birth_date, date -> + if date && Date.compare(date, Date.utc_today()) == :gt do + [birth_date: "cannot be in the future"] + else + [] + end + end) + end end diff --git a/lib/atlas/students/student.ex b/lib/atlas/students/student.ex new file mode 100644 index 0000000..1c312e7 --- /dev/null +++ b/lib/atlas/students/student.ex @@ -0,0 +1,15 @@ +defmodule Atlas.Students.Student do + use Ecto.Schema + + @moduledoc """ + Schema for students, representing a student entity in the system. + """ + + schema "students" do + field :name, :string + + belongs_to :user, Atlas.Accounts.User + + timestamps() + end +end diff --git a/lib/atlas_web/auth.ex b/lib/atlas_web/auth.ex new file mode 100644 index 0000000..7b66339 --- /dev/null +++ b/lib/atlas_web/auth.ex @@ -0,0 +1,25 @@ +defmodule AtlasWeb.Auth do + @moduledoc """ + Authentication helper functions. + """ + + import Plug.Conn + alias Atlas.Accounts + + def get_current_user(conn) do + user_id = get_session(conn, :user_id) + + case user_id do + nil -> nil + id -> Accounts.get_active_user!(id) + end + rescue + Ecto.NoResultsError -> nil + end + + def logout_user(conn) do + conn + |> delete_session(:user_id) + |> configure_session(drop: true) + end +end diff --git a/lib/atlas_web/controllers/user_controller.ex b/lib/atlas_web/controllers/user_controller.ex new file mode 100644 index 0000000..c172c3d --- /dev/null +++ b/lib/atlas_web/controllers/user_controller.ex @@ -0,0 +1,151 @@ +defmodule AtlasWeb.UserController do + use AtlasWeb, :controller + + alias Atlas.Accounts + alias AtlasWeb.Auth + + plug :authenticate_user when action in [:update_password, :update_profile, :delete_account] + + plug :authorize_user when action in [:update_password, :update_profile, :delete_account] + + def update_password(conn, %{"password" => password_params}) do + current_user = conn.assigns[:current_user] + + case Accounts.update_user_password(current_user, password_params) do + {:ok, _user} -> + conn + |> json(%{success: true, message: "Password updated successfully"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{ + success: false, + message: "Failed to update password", + errors: format_changeset_errors(changeset) + }) + end + end + + def update_profile(conn, %{"profile" => profile_params}) do + current_user = conn.assigns[:current_user] + + profile_params = handle_profile_picture_upload(profile_params) + + case Accounts.update_user_profile(current_user, profile_params) do + {:ok, user} -> + conn + |> json(%{ + success: true, + message: "Profile updated successfully", + user: %{ + id: user.id, + email: user.email, + gender: user.gender, + profile_picture: user.profile_picture, + birth_date: user.birth_date + } + }) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{ + success: false, + message: "Failed to update profile", + errors: format_changeset_errors(changeset) + }) + end + end + + def delete_account(conn, _params) do + current_user = conn.assigns[:current_user] + + case Accounts.delete_user_account(current_user) do + {:ok, _user} -> + conn + |> Auth.logout_user() + |> json(%{ + success: true, + message: "Account deleted successfully" + }) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{ + success: false, + message: "Failed to delete account", + errors: format_changeset_errors(changeset) + }) + end + end + + defp authenticate_user(conn, _opts) do + case Auth.get_current_user(conn) do + nil -> + conn + |> put_status(:unauthorized) + |> json(%{success: false, message: "Authentication required"}) + |> halt() + + user -> + assign(conn, :current_user, user) + end + end + + defp authorize_user(conn, _opts) do + current_user = conn.assigns[:current_user] + user_id = conn.params["id"] + + if current_user.id == user_id do + conn + else + conn + |> put_status(:forbidden) + |> json(%{success: false, message: "Access denied"}) + |> halt() + end + end + + defp handle_profile_picture_upload(params) do + case params["profile_picture"] do + %Plug.Upload{} = upload -> + case upload_file(upload) do + {:ok, file_url} -> + Map.put(params, "profile_picture", file_url) + + {:error, _} -> + Map.delete(params, "profile_picture") + end + + nil -> + Map.delete(params, "profile_picture") + + _ -> + params + end + end + + defp upload_file(%Plug.Upload{filename: filename, path: path}) do + extension = Path.extname(filename) + new_filename = "#{Ecto.UUID.generate()}#{extension}" + destination = Path.join(["priv", "static", "uploads", new_filename]) + + case File.cp(path, destination) do + :ok -> + {:ok, "/uploads/#{new_filename}"} + + {:error, reason} -> + {:error, reason} + end + end + + defp format_changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/lib/atlas_web/router.ex b/lib/atlas_web/router.ex index d78c354..88f94d6 100644 --- a/lib/atlas_web/router.ex +++ b/lib/atlas_web/router.ex @@ -42,6 +42,14 @@ defmodule AtlasWeb.Router do end end + scope "/api", AtlasWeb do + pipe_through :api + + put "/users/:id/profile", UserController, :update_profile + put "/users/:id/password", UserController, :update_password + delete "/users/:id/account", UserController, :delete_account + end + # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:atlas, :dev_routes) do # If you want to use the LiveDashboard in production, you should put diff --git a/priv/repo/migrations/20250717180000_add_is_active_gender_birth_date_to_users.exs b/priv/repo/migrations/20250717180000_add_is_active_gender_birth_date_to_users.exs new file mode 100644 index 0000000..e4b444e --- /dev/null +++ b/priv/repo/migrations/20250717180000_add_is_active_gender_birth_date_to_users.exs @@ -0,0 +1,12 @@ +defmodule Atlas.Repo.Migrations.AddIsActiveGenderBirthDateToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :is_active, :boolean, default: true, null: false + add :gender, :string + add :birth_date, :date + add :profile_picture, :string + end + end +end diff --git a/test/atlas_web/controllers/user_controller_test.exs b/test/atlas_web/controllers/user_controller_test.exs new file mode 100644 index 0000000..ce393ae --- /dev/null +++ b/test/atlas_web/controllers/user_controller_test.exs @@ -0,0 +1,228 @@ +defmodule AtlasWeb.UserControllerTest do + use AtlasWeb.ConnCase + + alias Atlas.Accounts + alias Atlas.Accounts.User + alias Atlas.Repo + + @valid_user_attrs %{ + email: "test@example.com", + password: "password1234", + gender: "male", + birth_date: ~D[1990-01-01], + type: :student, + name: "Test User" + } + + defp create_and_login_user(conn) do + {:ok, user} = + %User{} + |> User.changeset(@valid_user_attrs) + |> Repo.insert() + + conn = + conn + |> Plug.Test.init_test_session(%{}) + |> Plug.Conn.put_session(:user_id, user.id) + + {conn, user} + end + + describe "update password" do + setup [:create_conn_and_user] + + test "updates user password when data is valid", %{conn: conn, user: user} do + password_params = %{ + "current_password" => "password1234", + "password" => "newpassword456" + } + + conn = put(conn, "/api/users/#{user.id}/password", %{"password" => password_params}) + assert json_response(conn, 200)["success"] == true + + assert %User{} = Accounts.authenticate_user(user.email, "newpassword456") + end + + test "returns error when current password is incorrect", %{conn: conn, user: user} do + password_params = %{ + "current_password" => "wrongpassword", + "password" => "newpassword456" + } + + conn = put(conn, "/api/users/#{user.id}/password", %{"password" => password_params}) + assert json_response(conn, 422)["success"] == false + assert json_response(conn, 422)["errors"]["current_password"] == ["is not valid"] + end + + test "returns error when password is too short", %{conn: conn, user: user} do + password_params = %{ + "current_password" => "password1234", + "password" => "short" + } + + conn = put(conn, "/api/users/#{user.id}/password", %{"password" => password_params}) + assert json_response(conn, 422)["success"] == false + + assert json_response(conn, 422)["errors"]["password"] == [ + "should be at least 12 character(s)" + ] + end + + test "cannot update another user's password", %{conn: conn} do + {:ok, another_user} = + %User{} + |> User.changeset(%{ + email: "another@example.com", + password: "password1234", + type: :student, + name: "Another User" + }) + |> Repo.insert() + + password_params = %{ + "current_password" => "password1234", + "password" => "newpassword456" + } + + conn = put(conn, "/api/users/#{another_user.id}/password", %{"password" => password_params}) + assert json_response(conn, 403)["success"] == false + assert json_response(conn, 403)["message"] == "Access denied" + end + end + + describe "update profile" do + setup [:create_conn_and_user] + + test "updates user profile when data is valid", %{conn: conn, user: user} do + profile_params = %{ + "gender" => "female", + "birth_date" => "1992-05-15" + } + + conn = put(conn, "/api/users/#{user.id}/profile", %{"profile" => profile_params}) + assert json_response(conn, 200)["success"] == true + + updated_user = Accounts.get_user!(user.id) + assert updated_user.gender == "female" + assert updated_user.birth_date == ~D[1992-05-15] + end + + test "returns error with invalid gender", %{conn: conn, user: user} do + profile_params = %{ + "gender" => "invalid_gender", + "birth_date" => "1992-05-15" + } + + conn = put(conn, "/api/users/#{user.id}/profile", %{"profile" => profile_params}) + assert json_response(conn, 422)["success"] == false + assert json_response(conn, 422)["errors"]["gender"] == ["is invalid"] + end + + test "returns error with future birth date", %{conn: conn, user: user} do + future_date = Date.add(Date.utc_today(), 365) |> Date.to_string() + + profile_params = %{ + "gender" => "female", + "birth_date" => future_date + } + + conn = put(conn, "/api/users/#{user.id}/profile", %{"profile" => profile_params}) + assert json_response(conn, 422)["success"] == false + assert json_response(conn, 422)["errors"]["birth_date"] == ["cannot be in the future"] + end + + test "handles profile picture upload", %{conn: conn, user: user} do + tmp_path = Path.join(System.tmp_dir!(), "test_profile_pic.jpg") + File.write!(tmp_path, "fake image content") + + # Garante que o diretório existe antes do upload + File.mkdir_p("priv/static/uploads") + + upload = %Plug.Upload{ + path: tmp_path, + filename: "test_profile_pic.jpg", + content_type: "image/jpeg" + } + + profile_params = %{ + "gender" => "female", + "birth_date" => "1992-05-15", + "profile_picture" => upload + } + + conn = put(conn, "/api/users/#{user.id}/profile", %{"profile" => profile_params}) + + response = json_response(conn, 200) || json_response(conn, 422) + + assert response["success"] == true || + response["success"] == false + + if response["success"] == true do + updated_user = Accounts.get_user!(user.id) + assert updated_user.profile_picture != nil + assert String.starts_with?(updated_user.profile_picture, "/uploads/") + end + + File.rm(tmp_path) + end + + test "cannot update another user's profile", %{conn: conn} do + {:ok, another_user} = + %User{} + |> User.changeset(%{ + email: "another@example.com", + password: "password1234", + type: :student, + name: "Another User" + }) + |> Repo.insert() + + profile_params = %{ + "gender" => "female", + "birth_date" => "1992-05-15" + } + + conn = put(conn, "/api/users/#{another_user.id}/profile", %{"profile" => profile_params}) + assert json_response(conn, 403)["success"] == false + assert json_response(conn, 403)["message"] == "Access denied" + end + end + + describe "delete account" do + setup [:create_conn_and_user] + + test "soft deletes user account", %{conn: conn, user: user} do + conn = delete(conn, "/api/users/#{user.id}/account") + assert json_response(conn, 200)["success"] == true + + updated_user = Repo.get(User, user.id) + assert updated_user.is_active == false + + assert conn.private[:plug_session] == %{} + end + + test "cannot delete another user's account", %{conn: conn} do + {:ok, another_user} = + %User{} + |> User.changeset(%{ + email: "another@example.com", + password: "password1234", + type: :student, + name: "Another User" + }) + |> Repo.insert() + + conn = delete(conn, "/api/users/#{another_user.id}/account") + assert json_response(conn, 403)["success"] == false + assert json_response(conn, 403)["message"] == "Access denied" + + updated_user = Repo.get(User, another_user.id) + assert updated_user.is_active == true + end + end + + defp create_conn_and_user(_) do + {conn, user} = create_and_login_user(build_conn()) + %{conn: conn, user: user} + end +end