Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add user profile #321

Merged
merged 14 commits into from
Aug 19, 2023
16 changes: 16 additions & 0 deletions lib/atomic/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ defmodule Atomic.Accounts do
Repo.get_by(User, email: email)
end

@doc """
Gets a user by handle.

## Examples

iex> get_user_by_handle("foo_bar")
%User{}

iex> get_user_by_handle("unknown")
nil

"""
def get_user_by_handle(handle) when is_binary(handle) do
Repo.get_by(User, handle: handle)
end

@doc """
Gets a user by email and password.

Expand Down
15 changes: 14 additions & 1 deletion lib/atomic/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ defmodule Atomic.Accounts.User do
alias Atomic.Organizations.{Membership, Organization}
alias Atomic.Uploaders.ProfilePicture

@required_fields ~w(email password)a
@required_fields ~w(email handle password)a
joaodiaslobo marked this conversation as resolved.
Show resolved Hide resolved
@optional_fields ~w(name role confirmed_at course_id default_organization_id)a

@roles ~w(admin student)a

schema "users" do
field :name, :string
field :email, :string
field :handle, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :naive_datetime
Expand Down Expand Up @@ -53,6 +54,7 @@ defmodule Atomic.Accounts.User do
user
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_email()
|> validate_handle()
|> validate_password(opts)
end

Expand All @@ -79,6 +81,17 @@ defmodule Atomic.Accounts.User do
|> unique_constraint(:email)
end

defp validate_handle(changeset) do
changeset
|> validate_required([:handle])
|> validate_format(:handle, ~r/^[a-zA-Z0-9_.]+$/,
message: "must only contain alphanumeric characters, numbers, underscores and periods"
joaodiaslobo marked this conversation as resolved.
Show resolved Hide resolved
)
|> validate_length(:handle, min: 3, max: 30)
|> unsafe_validate_unique(:handle, Atomic.Repo)
joaodiaslobo marked this conversation as resolved.
Show resolved Hide resolved
|> unique_constraint(:handle)
end

defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
Expand Down
6 changes: 5 additions & 1 deletion lib/atomic_web/live/membership_live/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@
<%= for membership <- @memberships do %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6"><%= membership.number %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= membership.user.name %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<%= live_redirect to: Routes.user_show_path(@socket, :show, membership.user.handle) do %>
<%= membership.user.name %>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= membership.user.email %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">Phone number</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= display_date(membership.inserted_at) %> <%= display_time(membership.inserted_at) %></td>
Expand Down
32 changes: 32 additions & 0 deletions lib/atomic_web/live/user_live/show.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule AtomicWeb.UserLive.Show do
use AtomicWeb, :live_view

alias Atomic.Accounts

@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end

@impl true
def handle_params(%{"handle" => user_handle}, _, socket) do
user = Accounts.get_user_by_handle(user_handle)

organizations = Accounts.get_user_organizations(user)

entries = [
%{
name: gettext("%{name}", name: user.name),
route: Routes.user_show_path(socket, :show, user_handle)
}
]

{:noreply,
socket
|> assign(:page_title, user.name)
|> assign(:current_page, :profile)
|> assign(:breadcrumb_entries, entries)
|> assign(:user, user)
|> assign(:organizations, organizations)}
end
end
53 changes: 53 additions & 0 deletions lib/atomic_web/live/user_live/show.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<div>
<div class="pt-4 px-4">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1 space-y-2">
<h2 class="text-xl font-bold leading-7 text-zinc-900 sm:text-4xl">
<%= @user.name %>
</h2>
<p class="text-zinc-500">@<%= @user.handle %></p>
</div>
<div class="relative flex-shrink-0 w-24 h-24 rounded-full ring-2 ring-zinc-300 bg-zinc-400 sm:w-44 sm:h-44">
<span class="inline-flex justify-center items-center w-24 h-24 rounded-full sm:w-44 sm:h-44 bg-secondary">
<span class="text-4xl font-medium leading-none select-none text-white sm:text-6xl">
<%= Atomic.Accounts.extract_initials(@user.name) %>
</span>
</span>
</div>
</div>

<h3 class="text-xl font-semibold leading-7 text-zinc-900 sm:text-1xl">
<%= gettext("Organizations") %>
</h3>

<div class="grid grid-cols-1 gap-4 py-6 mb-2 border-b border-gray-200 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<%= for {organization, index} <- Enum.with_index(@organizations) do %>
<%= live_redirect to: Routes.organization_show_path(@socket, :show, organization) do %>
<div id={"organization-#{index}"} class="flex flex-row gap-x-4 items-center p-2 w-full h-20 rounded-xl border border-gray-200 hover:bg-gray-50">
<div>
<%= if organization.logo do %>
<span class="inline-flex justify-center items-center w-10 h-10 rounded-lg align-middle">
<img src={Atomic.Uploaders.Logo.url({organization.logo, organization}, :original)} class="w-10 h-10 rounded-lg" />
</span>
<% else %>
<span class="inline-flex justify-center items-center w-10 h-10 bg-zinc-300 rounded-lg">
<span class="text-lg font-medium leading-none text-white">
<%= Atomic.Accounts.extract_initials(organization.name) %>
</span>
</span>
<% end %>
</div>
<div class="flex overflow-hidden flex-col w-full">
<span class="font-medium inline-block overflow-hidden w-full text-base whitespace-nowrap text-ellipsis">
<%= organization.name %>
</span>
<span class="text-sm font-light capitalize text-zinc-500">
<%= Atomic.Organizations.get_role(@user.id, organization.id) %>
</span>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
2 changes: 2 additions & 0 deletions lib/atomic_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ defmodule AtomicWeb.Router do
live "/organizations", OrganizationLive.Index, :index
live "/organizations/:organization_id", OrganizationLive.Show, :show

live "/profile/:handle", UserLive.Show, :show

scope "/organizations/:organization_id" do
live "/board/", BoardLive.Index, :index
live "/board/:id", BoardLive.Show, :show
Expand Down
11 changes: 7 additions & 4 deletions lib/atomic_web/templates/layout/live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,14 @@
<Heroicons.Solid.calendar class="h-6 w-6 flex-shrink-0 text-zinc-400" />
<span class="">Calendar</span>
<% end %>
<%= live_redirect to: Routes.user_settings_path(@socket, :edit), class: "bg-zinc-200 flex items-center gap-x-4 px-6 py-3 text-sm font-semibold leading-6 text-gray-900" do %>
<%= live_redirect to: Routes.user_show_path(@socket, :show, @current_user.handle), class: "bg-zinc-200 flex items-center gap-x-4 px-6 py-3 text-sm font-semibold leading-6 text-gray-900" do %>
<span class="bg-zinc-400 rounded-lg w-10 h-10 flex items-center justify-center text-lg font-medium leading-none text-white">
<%= Atomic.Accounts.extract_initials(@current_user.email) %>
<%= Atomic.Accounts.extract_initials(@current_user.name) %>
</span>
<span>
<p aria-hidden="true"><%= @current_user.name %></p>
<p class="text-zinc-500 text-xs font-normal" aria-hidden="true">@<%= @current_user.handle %></p>
</span>
<span aria-hidden="true"><%= @current_user.email %></span>
<% end %>
</div>
</nav>
Expand All @@ -217,7 +220,7 @@
<%= if @current_organization do %>
<li>
<div>
<%= live_redirect to: Routes.activity_index_path(@socket, :index, @current_organization), class: "text-zinc-400 hover:text-zinc-500" do %>
<%= live_redirect to: Routes.home_index_path(@socket, :index), class: "text-zinc-400 hover:text-zinc-500" do %>
<svg class="h-5 w-5 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" clip-rule="evenodd" />
</svg>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Atomic.Repo.Migrations.CreateUsersAuthTables do
add :id, :binary_id, primary_key: true
add :name, :string
add :email, :citext, null: false
add :handle, :citext, null: false
joaodiaslobo marked this conversation as resolved.
Show resolved Hide resolved
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
add :profile_picture, :string
Expand All @@ -21,6 +22,7 @@ defmodule Atomic.Repo.Migrations.CreateUsersAuthTables do
end

create unique_index(:users, [:email])
create unique_index(:users, [:handle])

create table(:users_tokens, primary_key: false) do
add :id, :binary_id, primary_key: true
Expand Down
2 changes: 2 additions & 0 deletions priv/repo/seeds/accounts.exs
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,12 @@ defmodule Atomic.Repo.Seeds.Accounts do

for character <- characters do
email = (character |> String.downcase() |> String.replace(~r/\s*/, "")) <> "@mail.pt"
handle = character |> String.downcase() |> String.replace(~r/\s/, "_")

user = %{
"name" => character,
"email" => email,
"handle" => handle,
"password" => "password1234",
"role" => role,
"course_id" => Enum.random(courses).id,
Expand Down
22 changes: 16 additions & 6 deletions test/atomic/accounts_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,27 @@ defmodule Atomic.AccountsTest do
} = errors_on(changeset)
end

test "validates email and password when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})
test "validates email, handle and password when given" do
{:error, changeset} =
Accounts.register_user(%{email: "not valid", handle: "not valid", password: "not valid"})

assert %{
email: ["must have the @ sign and no spaces"],
handle: [
"must only contain alphanumeric characters, numbers, underscores and periods"
],
password: ["should be at least 12 character(s)"]
} = errors_on(changeset)
end

test "validates maximum values for email and password for security" do
test "validates maximum values for email, handle and password for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long})

{:error, changeset} =
Accounts.register_user(%{email: too_long, handle: too_long, password: too_long})

assert "should be at most 160 character(s)" in errors_on(changeset).email
assert "should be at most 30 character(s)" in errors_on(changeset).handle
assert "should be at most 72 character(s)" in errors_on(changeset).password
end

Expand All @@ -84,6 +92,7 @@ defmodule Atomic.AccountsTest do
{:ok, user} = Accounts.register_user(user_attrs)

assert user.email == user_attrs.email
assert user.handle == user_attrs.handle
assert is_binary(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_nil(user.password)
Expand All @@ -93,17 +102,18 @@ defmodule Atomic.AccountsTest do
describe "change_user_registration/2" do
test "returns a changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
assert changeset.required == [:password, :email]
assert changeset.required == [:password, :handle, :email]
joaodiaslobo marked this conversation as resolved.
Show resolved Hide resolved
end

test "allows fields to be set" do
email = Faker.Internet.email()
password = valid_user_password()
handle = Faker.Internet.user_name()

changeset =
Accounts.change_user_registration(
%User{},
params_for(:user, %{email: email, password: password})
params_for(:user, %{email: email, handle: handle, password: password})
)

assert changeset.valid?
Expand Down
1 change: 1 addition & 0 deletions test/support/factories/accounts_factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule Atomic.Factories.AccountFactory do
%User{
name: Faker.Person.name(),
email: Faker.Internet.email(),
handle: Faker.Internet.user_name(),
role: Enum.random(@roles),
hashed_password: Bcrypt.hash_pwd_salt("password1234")
}
Expand Down