Skip to content

Commit 83d5de9

Browse files
committed
user login with emailed token
1 parent e155940 commit 83d5de9

File tree

3 files changed

+145
-16
lines changed

3 files changed

+145
-16
lines changed

lib/algora/accounts/schemas/user.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ defmodule Algora.Accounts.User do
7373
field :og_title, :string
7474
field :og_image_url, :string
7575

76+
field :login_token, :string, virtual: true
77+
7678
has_many :identities, Identity
7779
has_many :memberships, Member, foreign_key: :user_id
7880
has_many :members, Member, foreign_key: :org_id
@@ -252,6 +254,10 @@ defmodule Algora.Accounts.User do
252254
|> validate_timezone()
253255
end
254256

257+
def login_changeset(%User{} = user, params) do
258+
cast(user, params, [:email, :login_token])
259+
end
260+
255261
defp validate_email(changeset) do
256262
changeset
257263
|> validate_required([:email])

lib/algora_web/controllers/user_auth.ex

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,15 @@ defmodule AlgoraWeb.UserAuth do
196196
defp login_code_ttl, do: 3600
197197
defp login_code_salt, do: "algora-login-code"
198198

199-
def generate_login_code(email, domain \\ nil, tech_stack) do
200-
payload = "#{email}:#{domain || ""}:#{Enum.join(tech_stack, ":")}"
199+
def generate_login_code(email) do
200+
sign_login_code(email)
201+
end
202+
203+
def generate_login_code(email, domain, tech_stack) do
204+
sign_login_code("#{email}:#{domain || ""}:#{Enum.join(tech_stack, ":")}")
205+
end
206+
207+
def sign_login_code(payload) do
201208
Phoenix.Token.sign(AlgoraWeb.Endpoint, login_code_salt(), payload, max_age: login_code_ttl())
202209
end
203210

@@ -257,7 +264,7 @@ defmodule AlgoraWeb.UserAuth do
257264
258265
For correspondence, please email the Algora founders at [email protected] and [email protected]
259266
260-
© 2023 Algora PBC.
267+
© 2025 Algora PBC.
261268
"""
262269
end
263270
end

lib/algora_web/live/sign_in_live.ex

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,56 @@ defmodule AlgoraWeb.SignInLive do
22
@moduledoc false
33
use AlgoraWeb, :live_view
44

5+
alias Algora.Accounts.User
6+
alias Swoosh.Email
7+
58
def render(assigns) do
69
~H"""
7-
<div class="min-h-[calc(100vh-64px)] flex flex-col justify-center">
8-
<div class="mb-[64px] mx-auto max-w-3xl p-12 sm:mx-auto sm:w-full sm:max-w-sm sm:p-24">
9-
<h2 class="text-center text-3xl font-extrabold text-gray-50">
10-
Algora Console
11-
</h2>
12-
<.link
13-
href={@authorize_url}
14-
rel="noopener"
15-
class="mt-8 flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2"
16-
>
17-
Sign in with GitHub
18-
</.link>
10+
<div class="mx-auto max-w-sm">
11+
<div class="min-h-[calc(100vh-64px)] flex flex-col justify-center">
12+
<div class="mb mx-auto max-w-3xl p-4 sm:mx-auto sm:w-full sm:max-w-sm">
13+
<h2 class="text-center text-3xl font-extrabold text-gray-50 p-4">
14+
Algora Console
15+
</h2>
16+
<.header class="text-center p-4">
17+
Sign in with Github
18+
<:subtitle>Sign in and link your Github Account</:subtitle>
19+
</.header>
20+
<.link
21+
href={@authorize_url}
22+
rel="noopener"
23+
class="mt-4 flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2"
24+
>
25+
Authorize with GitHub
26+
</.link>
27+
</div>
28+
<div class="border-t border-gray-800 my-4"></div>
29+
<.header class="text-center p-4">
30+
Sign in with Email
31+
<:subtitle :if={!@secret_code}>We'll send a login code to your inbox</:subtitle>
32+
<:subtitle :if={@secret_code}>We sent a login code to your inbox</:subtitle>
33+
</.header>
34+
35+
<.simple_form for={@form} id="send_login_code_form" phx-submit="send_login_code">
36+
<.input :if={!@secret_code} field={@form[:email]} type="email" placeholder="Email" required />
37+
<.input
38+
:if={@secret_code}
39+
field={@form[:login_code]}
40+
type="text"
41+
placeholder="Enter Login Code"
42+
required
43+
/>
44+
<:actions>
45+
<.button
46+
if={!@secret_code}
47+
phx-disable-with="Sending..."
48+
class="mt-4 flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2"
49+
>
50+
<span :if={!@secret_code}>Send login code</span>
51+
<div :if={@secret_code}>Login with code</div>
52+
</.button>
53+
</:actions>
54+
</.simple_form>
1955
</div>
2056
</div>
2157
"""
@@ -28,6 +64,86 @@ defmodule AlgoraWeb.SignInLive do
2864
return_to -> Algora.Github.authorize_url(%{return_to: return_to})
2965
end
3066

31-
{:ok, assign(socket, authorize_url: authorize_url)}
67+
changeset = User.login_changeset(%User{}, %{})
68+
69+
{:ok,
70+
socket
71+
|> assign(authorize_url: authorize_url)
72+
|> assign(secret_code: nil)
73+
|> assign_form(changeset)}
74+
end
75+
76+
def handle_event("send_login_code", %{"user" => %{"email" => email}}, socket) do
77+
code = Nanoid.generate()
78+
79+
case Algora.Accounts.get_user_by_email(email) do
80+
%User{} = user ->
81+
changeset = User.login_changeset(%User{}, %{})
82+
case send_login_code_to_user!(user, code) do
83+
{:ok, _id} ->
84+
{:noreply,
85+
socket
86+
|> assign(:secret_code, code)
87+
|> assign(:user, user)
88+
|> assign_form(changeset)
89+
|> put_flash(:info, "Login code sent to #{email}!")}
90+
91+
{:error, _reason} ->
92+
# capture_error reason
93+
{:noreply, put_flash(socket, :error, "We had trouble sending mail to #{email}. Please try again")}
94+
end
95+
96+
nil ->
97+
throttle()
98+
{:noreply, put_flash(socket, :error, "Email address not found.")}
99+
end
100+
end
101+
102+
def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do
103+
if Plug.Crypto.secure_compare(code, socket.assigns.secret_code) do
104+
user = socket.assigns.user
105+
token = AlgoraWeb.UserAuth.generate_login_code(user.email)
106+
path = AlgoraWeb.UserAuth.login_path(user.email, token)
107+
108+
{:noreply,
109+
socket
110+
|> redirect(to: path)
111+
|> put_flash(:info, "Logged in successfully!")}
112+
else
113+
throttle()
114+
{:noreply, put_flash(socket, :info, "Invalid login code")}
115+
end
32116
end
117+
118+
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
119+
assign(socket, :form, to_form(changeset))
120+
end
121+
122+
@from_name "Algora"
123+
@from_email "[email protected]"
124+
125+
defp send_login_code_to_user!(user, code) do
126+
email =
127+
Email.new()
128+
|> Email.to({user.display_name, user.email})
129+
|> Email.from({@from_name, @from_email})
130+
|> Email.subject("Login code for Algora")
131+
|> Email.text_body("""
132+
Here is your login code for Algora!
133+
134+
#{code}
135+
136+
If you didn't request this link, you can safely ignore this email.
137+
138+
--------------------------------------------------------------------------------
139+
140+
For correspondence, please email the Algora founders at [email protected] and [email protected]
141+
142+
© 2025 Algora PBC.
143+
""")
144+
145+
Algora.Mailer.deliver(email)
146+
end
147+
148+
defp throttle, do: :timer.sleep(1000)
33149
end

0 commit comments

Comments
 (0)