Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion lib/techvital_hub/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule TechvitalHub.Accounts do
"""

import Ecto.Query, warn: false
alias TechvitalHubWeb.UserAuth
alias TechvitalHub.Repo

alias TechvitalHub.Accounts.{User, UserNotifier, UserToken}
Expand Down Expand Up @@ -41,7 +42,29 @@ defmodule TechvitalHub.Accounts do
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user

cond do
is_nil(user) ->
Bcrypt.no_user_verify()
nil

UserAuth.account_locked?(user) ->
nil

User.valid_password?(user, password) ->
user
|> User.registration_changeset(%{failed_login_attempts: 0, locked_until: nil})
|> Repo.update!()

true ->
user
|> User.registration_changeset(%{
failed_login_attempts: (user.failed_login_attempts || 0) + 1
})
|> Repo.update!()

nil
end
end

@doc """
Expand Down
14 changes: 13 additions & 1 deletion lib/techvital_hub/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ defmodule TechvitalHub.Accounts.User do
field :hashed_password, :string, redact: true
field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime
field :failed_login_attempts, :integer, default: 0
field :last_failed_login, :utc_datetime
field :locked_until, :utc_datetime

many_to_many :courses, TechvitalHub.Courses.Course, join_through: "users_courses"

Expand Down Expand Up @@ -48,7 +51,16 @@ defmodule TechvitalHub.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :first_name, :last_name, :password, :role])
|> cast(attrs, [
:email,
:first_name,
:last_name,
:password,
:role,
:failed_login_attempts,
:last_failed_login,
:locked_until
])
|> validate_required([:first_name, :last_name, :role])
|> validate_email(opts)
|> validate_password(opts)
Expand Down
98 changes: 98 additions & 0 deletions lib/techvital_hub_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,104 @@ defmodule TechvitalHubWeb.CoreComponents do
"""
end

def logo(assigns) do
~H"""
<svg class="w-16 h-16" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
</linearGradient>
</defs>

<!-- Outer hexagon representing structure/foundation -->
<path
d="M 100 20 L 160 55 L 160 125 L 100 160 L 40 125 L 40 55 Z"
fill="none"
stroke="url(#grad1)"
stroke-width="3"
opacity="0.6"
/>

<!-- Middle rotating hexagon for dynamism -->
<path
d="M 100 40 L 140 62.5 L 140 107.5 L 100 130 L 60 107.5 L 60 62.5 Z"
fill="none"
stroke="url(#grad2)"
stroke-width="2.5"
opacity="0.8"
>
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 100 85"
to="360 100 85"
dur="20s"
repeatCount="indefinite"
/>
</path>

<!-- Central growth symbol - upward arrow with nodes -->
<g fill="url(#grad1)">
<!-- Bottom node -->
<circle cx="100" cy="110" r="5" />

<!-- Middle nodes -->
<circle cx="85" cy="90" r="5" />
<circle cx="115" cy="90" r="5" />

<!-- Top node -->
<circle cx="100" cy="70" r="5" />

<!-- Connecting lines -->
<line x1="100" y1="110" x2="85" y2="90" stroke="url(#grad1)" stroke-width="2.5" />
<line x1="100" y1="110" x2="115" y2="90" stroke="url(#grad1)" stroke-width="2.5" />
<line x1="85" y1="90" x2="100" y2="70" stroke="url(#grad1)" stroke-width="2.5" />
<line x1="115" y1="90" x2="100" y2="70" stroke="url(#grad1)" stroke-width="2.5" />

<!-- Upward arrow -->
<path
d="M 100 70 L 100 50 M 100 50 L 90 60 M 100 50 L 110 60"
fill="none"
stroke="url(#grad1)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>

<!-- Code brackets for tech aspect -->
<path
d="M 70 75 L 65 85 L 70 95"
fill="none"
stroke="url(#grad2)"
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
<path
d="M 130 75 L 135 85 L 130 95"
fill="none"
stroke="url(#grad2)"
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>

<!-- Pulsing effect on center -->
<circle cx="100" cy="85" r="35" fill="url(#grad1)" opacity="0.1">
<animate attributeName="r" values="30;40;30" dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.15;0.05;0.15" dur="3s" repeatCount="indefinite" />
</circle>
</svg>
"""
end

@doc """
Renders a [Heroicon](https://heroicons.com).

Expand Down
8 changes: 4 additions & 4 deletions lib/techvital_hub_web/components/home_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,9 @@ defmodule TechvitalHubWeb.HomeComponents do
<div class={
if is_nil(@current_user),
do:
"md:flex items-center justify-between border-b border-zinc-100 py-4 text-sm md:text-base lg:text-lg font-maven",
"md:flex items-center justify-between border-b border-zinc-100 text-sm md:text-base lg:text-lg font-maven",
else:
"flex items center justify-between border-b border-zinc-100 py-4 text-sm md:text-base lg:text-lg font-maven"
"flex items center justify-between border-b border-zinc-100 text-sm md:text-base lg:text-lg font-maven"
}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
Expand All @@ -443,11 +443,11 @@ defmodule TechvitalHubWeb.HomeComponents do
<img src={~p"/images/default.svg"} width="46" />
</a>
<a :if={@current_user.role != "admin"} href="/dashboard">
<img src={~p"/images/default.svg"} width="46" />
<CoreComponents.logo />
</a>
<% else %>
<a href="/">
<img src={~p"/images/default.svg"} width="46" />
<CoreComponents.logo />
</a>
<% end %>
<p class="hidden md:block text-brand rounded-full px-2 font-medium leading-6 text-4xl md:text-2xl lg:text-4xl">
Expand Down
41 changes: 31 additions & 10 deletions lib/techvital_hub_web/controllers/user_session_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,37 @@
defp create(conn, %{"user" => user_params}, info) do
%{"email" => email, "password" => password} = user_params

if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/login")
case Accounts.get_user_by_email(email) do
nil ->
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/login")

user ->
if UserAuth.account_locked?(user) do
minutes_remaining = DateTime.diff(user.locked_until, DateTime.utc_now(), :minute)

conn
|> put_flash(
:error,
"Account is locked. Please try again in #{minutes_remaining} minutes."
)
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/login")
else
if user = Accounts.get_user_by_email_and_password(email, password) do

Check warning on line 43 in lib/techvital_hub_web/controllers/user_session_controller.ex

View workflow job for this annotation

GitHub Actions / Credo

Function body is nested too deep (max depth is 2, was 3).
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/login")
end
end
end
end

Expand Down
35 changes: 35 additions & 0 deletions lib/techvital_hub_web/user_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ defmodule TechvitalHubWeb.UserAuth do

alias TechvitalHub.Accounts

@max_failed_login_attempts 5
@lockout_duration_minutes 30

# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
# the token expiry itself in UserToken.
Expand Down Expand Up @@ -283,4 +286,36 @@ defmodule TechvitalHubWeb.UserAuth do
defp signed_in_path(_conn, user) do
if user.role == "admin", do: ~p"/admin/dashboard", else: ~p"/dashboard"
end

@doc """
Returns true if the account is locked
"""
def account_locked?(user) do
if user.locked_until do
DateTime.compare(user.locked_until, DateTime.utc_now()) == :gt
else
false
end
end

@doc """
Increments the failed attempts counter and locks the account if max attempts reached
"""
def increment_failed_login_attempts(user) do
new_attempts = (user.failed_login_attempts || 0) + 1

if new_attempts >= @max_failed_login_attempts do
lock_until = DateTime.add(DateTime.utc_now(), @lockout_duration_minutes, :minute)
%{user | failed_login_attempts: new_attempts, locked_until: lock_until}
else
%{user | failed_login_attempts: new_attempts}
end
end

@doc """
Resets the failed attempts counter and unlocks the account
"""
def reset_failed_login_attempts(user) do
%{user | failed_login_attempts: 0, locked_until: nil}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule TechvitalHub.Repo.Migrations.AddFailedAttemptsToUsers do
use Ecto.Migration

def change do
alter table(:users) do
add :failed_login_attempts, :integer, default: 0
add :last_failed_login, :utc_datetime
add :locked_until, :utc_datetime
end
end
end
Binary file modified priv/static/favicon.ico
Binary file not shown.
75 changes: 75 additions & 0 deletions priv/static/images/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 0 additions & 6 deletions priv/static/images/logo.svg

This file was deleted.

Loading