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
29 changes: 29 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 Expand Up @@ -197,6 +213,19 @@ defmodule Atomic.Accounts do
User.email_changeset(user, attrs)
end

@doc """
Returns an `%Ecto.Changeset{}` for changing the user handle.

## Examples

iex> change_user_handle(user)
%Ecto.Changeset{data: %User{}}

"""
def change_user_handle(user, attrs \\ %{}) do
User.handle_changeset(user, attrs)
end

@doc """
Emulates that the email will change without actually changing
it in the database.
Expand Down
33 changes: 32 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,20 @@ 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:
Gettext.gettext(
"must only contain alphanumeric characters, numbers, underscores and periods"
)
)
|> 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 Expand Up @@ -119,6 +135,21 @@ defmodule Atomic.Accounts.User do
end
end

@doc """
A user changeset for changing the handle.

It requires the handle to change otherwise an error is added.
"""
def handle_changeset(user, attrs) do
user
|> cast(attrs, [:handle])
|> validate_handle()
|> case do
%{changes: %{handle: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :handle, "did not change")
end
end

@doc """
A user changeset for changing the 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
36 changes: 36 additions & 0 deletions lib/atomic_web/live/user_live/show.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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)

is_current_user =
Map.has_key?(socket.assigns, :current_user) and socket.assigns.current_user.id == user.id

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)
|> assign(:is_current_user, is_current_user)}
end
end
62 changes: 62 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,62 @@
<div>
<div class="pt-4 px-4">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-row">
<h2 class="text-xl font-bold leading-7 text-zinc-900 sm:text-4xl">
<%= @user.name %>
</h2>
<%= if @is_current_user do %>
<%= live_redirect to: Routes.user_settings_path(@socket, :edit) do %>
<div class="ml-4 outline-1 w-6 h-6 sm:w-8 sm:h-8 align-middle self-center sm:py-1">
<Heroicons.Outline.pencil class="p-[0.15rem] border sm:p-1 sm:border-2 stroke-zinc-400 rounded-lg border-zinc-400 hover:stroke-orange-400 hover:border-orange-400" />
</div>
<% end %>
<% end %>
</div>
<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
8 changes: 8 additions & 0 deletions lib/atomic_web/templates/user_registration/new.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
) %>
<%= error_tag(f, :email) %>

<%= label(f, :handle, class: "sr-only") %>
<%= text_input(f, :handle,
required: true,
placeholder: gettext("Username"),
class: "mt-1 relative block lg:w-96 w-full appearance-none rounded border border-zinc-300 px-3 py-2 text-zinc-900 placeholder-zinc-500 focus:z-10 focus:border-indigo-400 focus:outline-none sm:text-sm"
) %>
<%= error_tag(f, :handle) %>

<%= label(f, :password, class: "sr-only") %>
<%= password_input(f, :password,
required: true,
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
39 changes: 33 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 @@ -79,11 +87,22 @@ defmodule Atomic.AccountsTest do
assert "has already been taken" in errors_on(changeset).email
end

test "validates handle uniqueness" do
%{handle: handle} = insert(:user)
{:error, changeset} = Accounts.register_user(%{handle: handle})
assert "has already been taken" in errors_on(changeset).handle

# Now try with the upper cased handle too, to check that handle case is ignored.
{:error, changeset} = Accounts.register_user(%{handle: String.upcase(handle)})
assert "has already been taken" in errors_on(changeset).handle
end

test "registers users with a hashed password" do
user_attrs = params_for(:user) |> Map.put(:password, valid_user_password())
{: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 +112,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 All @@ -120,6 +140,13 @@ defmodule Atomic.AccountsTest do
end
end

describe "change_user_handle/2" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_handle(%User{})
assert changeset.required == [:handle]
end
end

describe "apply_user_email/3" do
setup do
%{user: insert(:user)}
Expand Down
Loading