timgremore commited on
Commit
26f4775
1 Parent(s): 69b993a

feat: Auth gen with argon2, live, and binary ID

Browse files
Files changed (31) hide show
  1. config/test.exs +3 -0
  2. lib/medical_transcription/accounts.ex +353 -0
  3. lib/medical_transcription/accounts/user.ex +157 -0
  4. lib/medical_transcription/accounts/user_notifier.ex +79 -0
  5. lib/medical_transcription/accounts/user_token.ex +181 -0
  6. lib/medical_transcription_web/components/layouts/root.html.heex +41 -0
  7. lib/medical_transcription_web/controllers/user_session_controller.ex +42 -0
  8. lib/medical_transcription_web/live/user_confirmation_instructions_live.ex +51 -0
  9. lib/medical_transcription_web/live/user_confirmation_live.ex +58 -0
  10. lib/medical_transcription_web/live/user_forgot_password_live.ex +50 -0
  11. lib/medical_transcription_web/live/user_login_live.ex +43 -0
  12. lib/medical_transcription_web/live/user_registration_live.ex +87 -0
  13. lib/medical_transcription_web/live/user_reset_password_live.ex +89 -0
  14. lib/medical_transcription_web/live/user_settings_live.ex +167 -0
  15. lib/medical_transcription_web/router.ex +41 -0
  16. lib/medical_transcription_web/user_auth.ex +227 -0
  17. mix.exs +1 -0
  18. mix.lock +2 -0
  19. priv/repo/migrations/20240202163628_create_users_auth_tables.exs +29 -0
  20. test/medical_transcription/accounts_test.exs +508 -0
  21. test/medical_transcription_web/controllers/user_session_controller_test.exs +113 -0
  22. test/medical_transcription_web/live/user_confirmation_instructions_live_test.exs +67 -0
  23. test/medical_transcription_web/live/user_confirmation_live_test.exs +89 -0
  24. test/medical_transcription_web/live/user_forgot_password_live_test.exs +63 -0
  25. test/medical_transcription_web/live/user_login_live_test.exs +87 -0
  26. test/medical_transcription_web/live/user_registration_live_test.exs +87 -0
  27. test/medical_transcription_web/live/user_reset_password_live_test.exs +118 -0
  28. test/medical_transcription_web/live/user_settings_live_test.exs +210 -0
  29. test/medical_transcription_web/user_auth_test.exs +272 -0
  30. test/support/conn_case.ex +26 -0
  31. test/support/fixtures/accounts_fixtures.ex +31 -0
config/test.exs CHANGED
@@ -1,5 +1,8 @@
1
  import Config
2
 
 
 
 
3
  # Configure your database
4
  #
5
  # The MIX_TEST_PARTITION environment variable can be used
 
1
  import Config
2
 
3
+ # Only in tests, remove the complexity from the password hashing algorithm
4
+ config :argon2_elixir, t_cost: 1, m_cost: 8
5
+
6
  # Configure your database
7
  #
8
  # The MIX_TEST_PARTITION environment variable can be used
lib/medical_transcription/accounts.ex ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.Accounts do
2
+ @moduledoc """
3
+ The Accounts context.
4
+ """
5
+
6
+ import Ecto.Query, warn: false
7
+ alias MedicalTranscription.Repo
8
+
9
+ alias MedicalTranscription.Accounts.{User, UserToken, UserNotifier}
10
+
11
+ ## Database getters
12
+
13
+ @doc """
14
+ Gets a user by email.
15
+
16
+ ## Examples
17
+
18
+ iex> get_user_by_email("foo@example.com")
19
+ %User{}
20
+
21
+ iex> get_user_by_email("unknown@example.com")
22
+ nil
23
+
24
+ """
25
+ def get_user_by_email(email) when is_binary(email) do
26
+ Repo.get_by(User, email: email)
27
+ end
28
+
29
+ @doc """
30
+ Gets a user by email and password.
31
+
32
+ ## Examples
33
+
34
+ iex> get_user_by_email_and_password("foo@example.com", "correct_password")
35
+ %User{}
36
+
37
+ iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
38
+ nil
39
+
40
+ """
41
+ def get_user_by_email_and_password(email, password)
42
+ when is_binary(email) and is_binary(password) do
43
+ user = Repo.get_by(User, email: email)
44
+ if User.valid_password?(user, password), do: user
45
+ end
46
+
47
+ @doc """
48
+ Gets a single user.
49
+
50
+ Raises `Ecto.NoResultsError` if the User does not exist.
51
+
52
+ ## Examples
53
+
54
+ iex> get_user!(123)
55
+ %User{}
56
+
57
+ iex> get_user!(456)
58
+ ** (Ecto.NoResultsError)
59
+
60
+ """
61
+ def get_user!(id), do: Repo.get!(User, id)
62
+
63
+ ## User registration
64
+
65
+ @doc """
66
+ Registers a user.
67
+
68
+ ## Examples
69
+
70
+ iex> register_user(%{field: value})
71
+ {:ok, %User{}}
72
+
73
+ iex> register_user(%{field: bad_value})
74
+ {:error, %Ecto.Changeset{}}
75
+
76
+ """
77
+ def register_user(attrs) do
78
+ %User{}
79
+ |> User.registration_changeset(attrs)
80
+ |> Repo.insert()
81
+ end
82
+
83
+ @doc """
84
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
85
+
86
+ ## Examples
87
+
88
+ iex> change_user_registration(user)
89
+ %Ecto.Changeset{data: %User{}}
90
+
91
+ """
92
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
93
+ User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
94
+ end
95
+
96
+ ## Settings
97
+
98
+ @doc """
99
+ Returns an `%Ecto.Changeset{}` for changing the user email.
100
+
101
+ ## Examples
102
+
103
+ iex> change_user_email(user)
104
+ %Ecto.Changeset{data: %User{}}
105
+
106
+ """
107
+ def change_user_email(user, attrs \\ %{}) do
108
+ User.email_changeset(user, attrs, validate_email: false)
109
+ end
110
+
111
+ @doc """
112
+ Emulates that the email will change without actually changing
113
+ it in the database.
114
+
115
+ ## Examples
116
+
117
+ iex> apply_user_email(user, "valid password", %{email: ...})
118
+ {:ok, %User{}}
119
+
120
+ iex> apply_user_email(user, "invalid password", %{email: ...})
121
+ {:error, %Ecto.Changeset{}}
122
+
123
+ """
124
+ def apply_user_email(user, password, attrs) do
125
+ user
126
+ |> User.email_changeset(attrs)
127
+ |> User.validate_current_password(password)
128
+ |> Ecto.Changeset.apply_action(:update)
129
+ end
130
+
131
+ @doc """
132
+ Updates the user email using the given token.
133
+
134
+ If the token matches, the user email is updated and the token is deleted.
135
+ The confirmed_at date is also updated to the current time.
136
+ """
137
+ def update_user_email(user, token) do
138
+ context = "change:#{user.email}"
139
+
140
+ with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
141
+ %UserToken{sent_to: email} <- Repo.one(query),
142
+ {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
143
+ :ok
144
+ else
145
+ _ -> :error
146
+ end
147
+ end
148
+
149
+ defp user_email_multi(user, email, context) do
150
+ changeset =
151
+ user
152
+ |> User.email_changeset(%{email: email})
153
+ |> User.confirm_changeset()
154
+
155
+ Ecto.Multi.new()
156
+ |> Ecto.Multi.update(:user, changeset)
157
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context]))
158
+ end
159
+
160
+ @doc ~S"""
161
+ Delivers the update email instructions to the given user.
162
+
163
+ ## Examples
164
+
165
+ iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1})")
166
+ {:ok, %{to: ..., body: ...}}
167
+
168
+ """
169
+ def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
170
+ when is_function(update_email_url_fun, 1) do
171
+ {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
172
+
173
+ Repo.insert!(user_token)
174
+ UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
175
+ end
176
+
177
+ @doc """
178
+ Returns an `%Ecto.Changeset{}` for changing the user password.
179
+
180
+ ## Examples
181
+
182
+ iex> change_user_password(user)
183
+ %Ecto.Changeset{data: %User{}}
184
+
185
+ """
186
+ def change_user_password(user, attrs \\ %{}) do
187
+ User.password_changeset(user, attrs, hash_password: false)
188
+ end
189
+
190
+ @doc """
191
+ Updates the user password.
192
+
193
+ ## Examples
194
+
195
+ iex> update_user_password(user, "valid password", %{password: ...})
196
+ {:ok, %User{}}
197
+
198
+ iex> update_user_password(user, "invalid password", %{password: ...})
199
+ {:error, %Ecto.Changeset{}}
200
+
201
+ """
202
+ def update_user_password(user, password, attrs) do
203
+ changeset =
204
+ user
205
+ |> User.password_changeset(attrs)
206
+ |> User.validate_current_password(password)
207
+
208
+ Ecto.Multi.new()
209
+ |> Ecto.Multi.update(:user, changeset)
210
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
211
+ |> Repo.transaction()
212
+ |> case do
213
+ {:ok, %{user: user}} -> {:ok, user}
214
+ {:error, :user, changeset, _} -> {:error, changeset}
215
+ end
216
+ end
217
+
218
+ ## Session
219
+
220
+ @doc """
221
+ Generates a session token.
222
+ """
223
+ def generate_user_session_token(user) do
224
+ {token, user_token} = UserToken.build_session_token(user)
225
+ Repo.insert!(user_token)
226
+ token
227
+ end
228
+
229
+ @doc """
230
+ Gets the user with the given signed token.
231
+ """
232
+ def get_user_by_session_token(token) do
233
+ {:ok, query} = UserToken.verify_session_token_query(token)
234
+ Repo.one(query)
235
+ end
236
+
237
+ @doc """
238
+ Deletes the signed token with the given context.
239
+ """
240
+ def delete_user_session_token(token) do
241
+ Repo.delete_all(UserToken.by_token_and_context_query(token, "session"))
242
+ :ok
243
+ end
244
+
245
+ ## Confirmation
246
+
247
+ @doc ~S"""
248
+ Delivers the confirmation email instructions to the given user.
249
+
250
+ ## Examples
251
+
252
+ iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
253
+ {:ok, %{to: ..., body: ...}}
254
+
255
+ iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
256
+ {:error, :already_confirmed}
257
+
258
+ """
259
+ def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
260
+ when is_function(confirmation_url_fun, 1) do
261
+ if user.confirmed_at do
262
+ {:error, :already_confirmed}
263
+ else
264
+ {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
265
+ Repo.insert!(user_token)
266
+ UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
267
+ end
268
+ end
269
+
270
+ @doc """
271
+ Confirms a user by the given token.
272
+
273
+ If the token matches, the user account is marked as confirmed
274
+ and the token is deleted.
275
+ """
276
+ def confirm_user(token) do
277
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
278
+ %User{} = user <- Repo.one(query),
279
+ {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
280
+ {:ok, user}
281
+ else
282
+ _ -> :error
283
+ end
284
+ end
285
+
286
+ defp confirm_user_multi(user) do
287
+ Ecto.Multi.new()
288
+ |> Ecto.Multi.update(:user, User.confirm_changeset(user))
289
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"]))
290
+ end
291
+
292
+ ## Reset password
293
+
294
+ @doc ~S"""
295
+ Delivers the reset password email to the given user.
296
+
297
+ ## Examples
298
+
299
+ iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
300
+ {:ok, %{to: ..., body: ...}}
301
+
302
+ """
303
+ def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
304
+ when is_function(reset_password_url_fun, 1) do
305
+ {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
306
+ Repo.insert!(user_token)
307
+ UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
308
+ end
309
+
310
+ @doc """
311
+ Gets the user by reset password token.
312
+
313
+ ## Examples
314
+
315
+ iex> get_user_by_reset_password_token("validtoken")
316
+ %User{}
317
+
318
+ iex> get_user_by_reset_password_token("invalidtoken")
319
+ nil
320
+
321
+ """
322
+ def get_user_by_reset_password_token(token) do
323
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
324
+ %User{} = user <- Repo.one(query) do
325
+ user
326
+ else
327
+ _ -> nil
328
+ end
329
+ end
330
+
331
+ @doc """
332
+ Resets the user password.
333
+
334
+ ## Examples
335
+
336
+ iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
337
+ {:ok, %User{}}
338
+
339
+ iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
340
+ {:error, %Ecto.Changeset{}}
341
+
342
+ """
343
+ def reset_user_password(user, attrs) do
344
+ Ecto.Multi.new()
345
+ |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
346
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
347
+ |> Repo.transaction()
348
+ |> case do
349
+ {:ok, %{user: user}} -> {:ok, user}
350
+ {:error, :user, changeset, _} -> {:error, changeset}
351
+ end
352
+ end
353
+ end
lib/medical_transcription/accounts/user.ex ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.Accounts.User do
2
+ use Ecto.Schema
3
+ import Ecto.Changeset
4
+ @primary_key {:id, :binary_id, autogenerate: true}
5
+ @foreign_key_type :binary_id
6
+ schema "users" do
7
+ field :email, :string
8
+ field :password, :string, virtual: true, redact: true
9
+ field :hashed_password, :string, redact: true
10
+ field :confirmed_at, :naive_datetime
11
+
12
+ timestamps(type: :utc_datetime)
13
+ end
14
+
15
+ @doc """
16
+ A user changeset for registration.
17
+
18
+ It is important to validate the length of both email and password.
19
+ Otherwise databases may truncate the email without warnings, which
20
+ could lead to unpredictable or insecure behaviour. Long passwords may
21
+ also be very expensive to hash for certain algorithms.
22
+
23
+ ## Options
24
+
25
+ * `:hash_password` - Hashes the password so it can be stored securely
26
+ in the database and ensures the password field is cleared to prevent
27
+ leaks in the logs. If password hashing is not needed and clearing the
28
+ password field is not desired (like when using this changeset for
29
+ validations on a LiveView form), this option can be set to `false`.
30
+ Defaults to `true`.
31
+
32
+ * `:validate_email` - Validates the uniqueness of the email, in case
33
+ you don't want to validate the uniqueness of the email (like when
34
+ using this changeset for validations on a LiveView form before
35
+ submitting the form), this option can be set to `false`.
36
+ Defaults to `true`.
37
+ """
38
+ def registration_changeset(user, attrs, opts \\ []) do
39
+ user
40
+ |> cast(attrs, [:email, :password])
41
+ |> validate_email(opts)
42
+ |> validate_password(opts)
43
+ end
44
+
45
+ defp validate_email(changeset, opts) do
46
+ changeset
47
+ |> validate_required([:email])
48
+ |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
49
+ |> validate_length(:email, max: 160)
50
+ |> maybe_validate_unique_email(opts)
51
+ end
52
+
53
+ defp validate_password(changeset, opts) do
54
+ changeset
55
+ |> validate_required([:password])
56
+ |> validate_length(:password, min: 12, max: 72)
57
+ # Examples of additional password validation:
58
+ # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
59
+ # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
60
+ # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
61
+ |> maybe_hash_password(opts)
62
+ end
63
+
64
+ defp maybe_hash_password(changeset, opts) do
65
+ hash_password? = Keyword.get(opts, :hash_password, true)
66
+ password = get_change(changeset, :password)
67
+
68
+ if hash_password? && password && changeset.valid? do
69
+ changeset
70
+ # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
71
+ # would keep the database transaction open longer and hurt performance.
72
+ |> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
73
+ |> delete_change(:password)
74
+ else
75
+ changeset
76
+ end
77
+ end
78
+
79
+ defp maybe_validate_unique_email(changeset, opts) do
80
+ if Keyword.get(opts, :validate_email, true) do
81
+ changeset
82
+ |> unsafe_validate_unique(:email, MedicalTranscription.Repo)
83
+ |> unique_constraint(:email)
84
+ else
85
+ changeset
86
+ end
87
+ end
88
+
89
+ @doc """
90
+ A user changeset for changing the email.
91
+
92
+ It requires the email to change otherwise an error is added.
93
+ """
94
+ def email_changeset(user, attrs, opts \\ []) do
95
+ user
96
+ |> cast(attrs, [:email])
97
+ |> validate_email(opts)
98
+ |> case do
99
+ %{changes: %{email: _}} = changeset -> changeset
100
+ %{} = changeset -> add_error(changeset, :email, "did not change")
101
+ end
102
+ end
103
+
104
+ @doc """
105
+ A user changeset for changing the password.
106
+
107
+ ## Options
108
+
109
+ * `:hash_password` - Hashes the password so it can be stored securely
110
+ in the database and ensures the password field is cleared to prevent
111
+ leaks in the logs. If password hashing is not needed and clearing the
112
+ password field is not desired (like when using this changeset for
113
+ validations on a LiveView form), this option can be set to `false`.
114
+ Defaults to `true`.
115
+ """
116
+ def password_changeset(user, attrs, opts \\ []) do
117
+ user
118
+ |> cast(attrs, [:password])
119
+ |> validate_confirmation(:password, message: "does not match password")
120
+ |> validate_password(opts)
121
+ end
122
+
123
+ @doc """
124
+ Confirms the account by setting `confirmed_at`.
125
+ """
126
+ def confirm_changeset(user) do
127
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
128
+ change(user, confirmed_at: now)
129
+ end
130
+
131
+ @doc """
132
+ Verifies the password.
133
+
134
+ If there is no user or the user doesn't have a password, we call
135
+ `Argon2.no_user_verify/0` to avoid timing attacks.
136
+ """
137
+ def valid_password?(%MedicalTranscription.Accounts.User{hashed_password: hashed_password}, password)
138
+ when is_binary(hashed_password) and byte_size(password) > 0 do
139
+ Argon2.verify_pass(password, hashed_password)
140
+ end
141
+
142
+ def valid_password?(_, _) do
143
+ Argon2.no_user_verify()
144
+ false
145
+ end
146
+
147
+ @doc """
148
+ Validates the current password otherwise adds an error to the changeset.
149
+ """
150
+ def validate_current_password(changeset, password) do
151
+ if valid_password?(changeset.data, password) do
152
+ changeset
153
+ else
154
+ add_error(changeset, :current_password, "is not valid")
155
+ end
156
+ end
157
+ end
lib/medical_transcription/accounts/user_notifier.ex ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.Accounts.UserNotifier do
2
+ import Swoosh.Email
3
+
4
+ alias MedicalTranscription.Mailer
5
+
6
+ # Delivers the email using the application mailer.
7
+ defp deliver(recipient, subject, body) do
8
+ email =
9
+ new()
10
+ |> to(recipient)
11
+ |> from({"MedicalTranscription", "contact@example.com"})
12
+ |> subject(subject)
13
+ |> text_body(body)
14
+
15
+ with {:ok, _metadata} <- Mailer.deliver(email) do
16
+ {:ok, email}
17
+ end
18
+ end
19
+
20
+ @doc """
21
+ Deliver instructions to confirm account.
22
+ """
23
+ def deliver_confirmation_instructions(user, url) do
24
+ deliver(user.email, "Confirmation instructions", """
25
+
26
+ ==============================
27
+
28
+ Hi #{user.email},
29
+
30
+ You can confirm your account by visiting the URL below:
31
+
32
+ #{url}
33
+
34
+ If you didn't create an account with us, please ignore this.
35
+
36
+ ==============================
37
+ """)
38
+ end
39
+
40
+ @doc """
41
+ Deliver instructions to reset a user password.
42
+ """
43
+ def deliver_reset_password_instructions(user, url) do
44
+ deliver(user.email, "Reset password instructions", """
45
+
46
+ ==============================
47
+
48
+ Hi #{user.email},
49
+
50
+ You can reset your password by visiting the URL below:
51
+
52
+ #{url}
53
+
54
+ If you didn't request this change, please ignore this.
55
+
56
+ ==============================
57
+ """)
58
+ end
59
+
60
+ @doc """
61
+ Deliver instructions to update a user email.
62
+ """
63
+ def deliver_update_email_instructions(user, url) do
64
+ deliver(user.email, "Update email instructions", """
65
+
66
+ ==============================
67
+
68
+ Hi #{user.email},
69
+
70
+ You can change your email by visiting the URL below:
71
+
72
+ #{url}
73
+
74
+ If you didn't request this change, please ignore this.
75
+
76
+ ==============================
77
+ """)
78
+ end
79
+ end
lib/medical_transcription/accounts/user_token.ex ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.Accounts.UserToken do
2
+ use Ecto.Schema
3
+ import Ecto.Query
4
+ alias MedicalTranscription.Accounts.UserToken
5
+
6
+ @hash_algorithm :sha256
7
+ @rand_size 32
8
+
9
+ # It is very important to keep the reset password token expiry short,
10
+ # since someone with access to the email may take over the account.
11
+ @reset_password_validity_in_days 1
12
+ @confirm_validity_in_days 7
13
+ @change_email_validity_in_days 7
14
+ @session_validity_in_days 60
15
+
16
+ @primary_key {:id, :binary_id, autogenerate: true}
17
+ @foreign_key_type :binary_id
18
+ schema "users_tokens" do
19
+ field :token, :binary
20
+ field :context, :string
21
+ field :sent_to, :string
22
+ belongs_to :user, MedicalTranscription.Accounts.User
23
+
24
+ timestamps(updated_at: false)
25
+ end
26
+
27
+ @doc """
28
+ Generates a token that will be stored in a signed place,
29
+ such as session or cookie. As they are signed, those
30
+ tokens do not need to be hashed.
31
+
32
+ The reason why we store session tokens in the database, even
33
+ though Phoenix already provides a session cookie, is because
34
+ Phoenix' default session cookies are not persisted, they are
35
+ simply signed and potentially encrypted. This means they are
36
+ valid indefinitely, unless you change the signing/encryption
37
+ salt.
38
+
39
+ Therefore, storing them allows individual user
40
+ sessions to be expired. The token system can also be extended
41
+ to store additional data, such as the device used for logging in.
42
+ You could then use this information to display all valid sessions
43
+ and devices in the UI and allow users to explicitly expire any
44
+ session they deem invalid.
45
+ """
46
+ def build_session_token(user) do
47
+ token = :crypto.strong_rand_bytes(@rand_size)
48
+ {token, %UserToken{token: token, context: "session", user_id: user.id}}
49
+ end
50
+
51
+ @doc """
52
+ Checks if the token is valid and returns its underlying lookup query.
53
+
54
+ The query returns the user found by the token, if any.
55
+
56
+ The token is valid if it matches the value in the database and it has
57
+ not expired (after @session_validity_in_days).
58
+ """
59
+ def verify_session_token_query(token) do
60
+ query =
61
+ from token in by_token_and_context_query(token, "session"),
62
+ join: user in assoc(token, :user),
63
+ where: token.inserted_at > ago(@session_validity_in_days, "day"),
64
+ select: user
65
+
66
+ {:ok, query}
67
+ end
68
+
69
+ @doc """
70
+ Builds a token and its hash to be delivered to the user's email.
71
+
72
+ The non-hashed token is sent to the user email while the
73
+ hashed part is stored in the database. The original token cannot be reconstructed,
74
+ which means anyone with read-only access to the database cannot directly use
75
+ the token in the application to gain access. Furthermore, if the user changes
76
+ their email in the system, the tokens sent to the previous email are no longer
77
+ valid.
78
+
79
+ Users can easily adapt the existing code to provide other types of delivery methods,
80
+ for example, by phone numbers.
81
+ """
82
+ def build_email_token(user, context) do
83
+ build_hashed_token(user, context, user.email)
84
+ end
85
+
86
+ defp build_hashed_token(user, context, sent_to) do
87
+ token = :crypto.strong_rand_bytes(@rand_size)
88
+ hashed_token = :crypto.hash(@hash_algorithm, token)
89
+
90
+ {Base.url_encode64(token, padding: false),
91
+ %UserToken{
92
+ token: hashed_token,
93
+ context: context,
94
+ sent_to: sent_to,
95
+ user_id: user.id
96
+ }}
97
+ end
98
+
99
+ @doc """
100
+ Checks if the token is valid and returns its underlying lookup query.
101
+
102
+ The query returns the user found by the token, if any.
103
+
104
+ The given token is valid if it matches its hashed counterpart in the
105
+ database and the user email has not changed. This function also checks
106
+ if the token is being used within a certain period, depending on the
107
+ context. The default contexts supported by this function are either
108
+ "confirm", for account confirmation emails, and "reset_password",
109
+ for resetting the password. For verifying requests to change the email,
110
+ see `verify_change_email_token_query/2`.
111
+ """
112
+ def verify_email_token_query(token, context) do
113
+ case Base.url_decode64(token, padding: false) do
114
+ {:ok, decoded_token} ->
115
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
116
+ days = days_for_context(context)
117
+
118
+ query =
119
+ from token in by_token_and_context_query(hashed_token, context),
120
+ join: user in assoc(token, :user),
121
+ where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
122
+ select: user
123
+
124
+ {:ok, query}
125
+
126
+ :error ->
127
+ :error
128
+ end
129
+ end
130
+
131
+ defp days_for_context("confirm"), do: @confirm_validity_in_days
132
+ defp days_for_context("reset_password"), do: @reset_password_validity_in_days
133
+
134
+ @doc """
135
+ Checks if the token is valid and returns its underlying lookup query.
136
+
137
+ The query returns the user found by the token, if any.
138
+
139
+ This is used to validate requests to change the user
140
+ email. It is different from `verify_email_token_query/2` precisely because
141
+ `verify_email_token_query/2` validates the email has not changed, which is
142
+ the starting point by this function.
143
+
144
+ The given token is valid if it matches its hashed counterpart in the
145
+ database and if it has not expired (after @change_email_validity_in_days).
146
+ The context must always start with "change:".
147
+ """
148
+ def verify_change_email_token_query(token, "change:" <> _ = context) do
149
+ case Base.url_decode64(token, padding: false) do
150
+ {:ok, decoded_token} ->
151
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
152
+
153
+ query =
154
+ from token in by_token_and_context_query(hashed_token, context),
155
+ where: token.inserted_at > ago(@change_email_validity_in_days, "day")
156
+
157
+ {:ok, query}
158
+
159
+ :error ->
160
+ :error
161
+ end
162
+ end
163
+
164
+ @doc """
165
+ Returns the token struct for the given token value and context.
166
+ """
167
+ def by_token_and_context_query(token, context) do
168
+ from UserToken, where: [token: ^token, context: ^context]
169
+ end
170
+
171
+ @doc """
172
+ Gets all tokens for the given user for the given contexts.
173
+ """
174
+ def by_user_and_contexts_query(user, :all) do
175
+ from t in UserToken, where: t.user_id == ^user.id
176
+ end
177
+
178
+ def by_user_and_contexts_query(user, [_ | _] = contexts) do
179
+ from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
180
+ end
181
+ end
lib/medical_transcription_web/components/layouts/root.html.heex CHANGED
@@ -12,6 +12,47 @@
12
  </script>
13
  </head>
14
  <body class="bg-white antialiased px-5 py-7">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  <%= @inner_content %>
16
  </body>
17
  </html>
 
12
  </script>
13
  </head>
14
  <body class="bg-white antialiased px-5 py-7">
15
+ <ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
16
+ <%= if @current_user do %>
17
+ <li class="text-[0.8125rem] leading-6 text-zinc-900">
18
+ <%= @current_user.email %>
19
+ </li>
20
+ <li>
21
+ <.link
22
+ href={~p"/users/settings"}
23
+ class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
24
+ >
25
+ Settings
26
+ </.link>
27
+ </li>
28
+ <li>
29
+ <.link
30
+ href={~p"/users/log_out"}
31
+ method="delete"
32
+ class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
33
+ >
34
+ Log out
35
+ </.link>
36
+ </li>
37
+ <% else %>
38
+ <li>
39
+ <.link
40
+ href={~p"/users/register"}
41
+ class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
42
+ >
43
+ Register
44
+ </.link>
45
+ </li>
46
+ <li>
47
+ <.link
48
+ href={~p"/users/log_in"}
49
+ class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
50
+ >
51
+ Log in
52
+ </.link>
53
+ </li>
54
+ <% end %>
55
+ </ul>
56
  <%= @inner_content %>
57
  </body>
58
  </html>
lib/medical_transcription_web/controllers/user_session_controller.ex ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserSessionController do
2
+ use MedicalTranscriptionWeb, :controller
3
+
4
+ alias MedicalTranscription.Accounts
5
+ alias MedicalTranscriptionWeb.UserAuth
6
+
7
+ def create(conn, %{"_action" => "registered"} = params) do
8
+ create(conn, params, "Account created successfully!")
9
+ end
10
+
11
+ def create(conn, %{"_action" => "password_updated"} = params) do
12
+ conn
13
+ |> put_session(:user_return_to, ~p"/users/settings")
14
+ |> create(params, "Password updated successfully!")
15
+ end
16
+
17
+ def create(conn, params) do
18
+ create(conn, params, "Welcome back!")
19
+ end
20
+
21
+ defp create(conn, %{"user" => user_params}, info) do
22
+ %{"email" => email, "password" => password} = user_params
23
+
24
+ if user = Accounts.get_user_by_email_and_password(email, password) do
25
+ conn
26
+ |> put_flash(:info, info)
27
+ |> UserAuth.log_in_user(user, user_params)
28
+ else
29
+ # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
30
+ conn
31
+ |> put_flash(:error, "Invalid email or password")
32
+ |> put_flash(:email, String.slice(email, 0, 160))
33
+ |> redirect(to: ~p"/users/log_in")
34
+ end
35
+ end
36
+
37
+ def delete(conn, _params) do
38
+ conn
39
+ |> put_flash(:info, "Logged out successfully.")
40
+ |> UserAuth.log_out_user()
41
+ end
42
+ end
lib/medical_transcription_web/live/user_confirmation_instructions_live.ex ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserConfirmationInstructionsLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ alias MedicalTranscription.Accounts
5
+
6
+ def render(assigns) do
7
+ ~H"""
8
+ <div class="mx-auto max-w-sm">
9
+ <.header class="text-center">
10
+ No confirmation instructions received?
11
+ <:subtitle>We'll send a new confirmation link to your inbox</:subtitle>
12
+ </.header>
13
+
14
+ <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
15
+ <.input field={@form[:email]} type="email" placeholder="Email" required />
16
+ <:actions>
17
+ <.button phx-disable-with="Sending..." class="w-full">
18
+ Resend confirmation instructions
19
+ </.button>
20
+ </:actions>
21
+ </.simple_form>
22
+
23
+ <p class="text-center mt-4">
24
+ <.link href={~p"/users/register"}>Register</.link>
25
+ | <.link href={~p"/users/log_in"}>Log in</.link>
26
+ </p>
27
+ </div>
28
+ """
29
+ end
30
+
31
+ def mount(_params, _session, socket) do
32
+ {:ok, assign(socket, form: to_form(%{}, as: "user"))}
33
+ end
34
+
35
+ def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
36
+ if user = Accounts.get_user_by_email(email) do
37
+ Accounts.deliver_user_confirmation_instructions(
38
+ user,
39
+ &url(~p"/users/confirm/#{&1}")
40
+ )
41
+ end
42
+
43
+ info =
44
+ "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
45
+
46
+ {:noreply,
47
+ socket
48
+ |> put_flash(:info, info)
49
+ |> redirect(to: ~p"/")}
50
+ end
51
+ end
lib/medical_transcription_web/live/user_confirmation_live.ex ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserConfirmationLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ alias MedicalTranscription.Accounts
5
+
6
+ def render(%{live_action: :edit} = assigns) do
7
+ ~H"""
8
+ <div class="mx-auto max-w-sm">
9
+ <.header class="text-center">Confirm Account</.header>
10
+
11
+ <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
12
+ <.input field={@form[:token]} type="hidden" />
13
+ <:actions>
14
+ <.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
15
+ </:actions>
16
+ </.simple_form>
17
+
18
+ <p class="text-center mt-4">
19
+ <.link href={~p"/users/register"}>Register</.link>
20
+ | <.link href={~p"/users/log_in"}>Log in</.link>
21
+ </p>
22
+ </div>
23
+ """
24
+ end
25
+
26
+ def mount(%{"token" => token}, _session, socket) do
27
+ form = to_form(%{"token" => token}, as: "user")
28
+ {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
29
+ end
30
+
31
+ # Do not log in the user after confirmation to avoid a
32
+ # leaked token giving the user access to the account.
33
+ def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
34
+ case Accounts.confirm_user(token) do
35
+ {:ok, _} ->
36
+ {:noreply,
37
+ socket
38
+ |> put_flash(:info, "User confirmed successfully.")
39
+ |> redirect(to: ~p"/")}
40
+
41
+ :error ->
42
+ # If there is a current user and the account was already confirmed,
43
+ # then odds are that the confirmation link was already visited, either
44
+ # by some automation or by the user themselves, so we redirect without
45
+ # a warning message.
46
+ case socket.assigns do
47
+ %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
48
+ {:noreply, redirect(socket, to: ~p"/")}
49
+
50
+ %{} ->
51
+ {:noreply,
52
+ socket
53
+ |> put_flash(:error, "User confirmation link is invalid or it has expired.")
54
+ |> redirect(to: ~p"/")}
55
+ end
56
+ end
57
+ end
58
+ end
lib/medical_transcription_web/live/user_forgot_password_live.ex ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserForgotPasswordLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ alias MedicalTranscription.Accounts
5
+
6
+ def render(assigns) do
7
+ ~H"""
8
+ <div class="mx-auto max-w-sm">
9
+ <.header class="text-center">
10
+ Forgot your password?
11
+ <:subtitle>We'll send a password reset link to your inbox</:subtitle>
12
+ </.header>
13
+
14
+ <.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
15
+ <.input field={@form[:email]} type="email" placeholder="Email" required />
16
+ <:actions>
17
+ <.button phx-disable-with="Sending..." class="w-full">
18
+ Send password reset instructions
19
+ </.button>
20
+ </:actions>
21
+ </.simple_form>
22
+ <p class="text-center text-sm mt-4">
23
+ <.link href={~p"/users/register"}>Register</.link>
24
+ | <.link href={~p"/users/log_in"}>Log in</.link>
25
+ </p>
26
+ </div>
27
+ """
28
+ end
29
+
30
+ def mount(_params, _session, socket) do
31
+ {:ok, assign(socket, form: to_form(%{}, as: "user"))}
32
+ end
33
+
34
+ def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
35
+ if user = Accounts.get_user_by_email(email) do
36
+ Accounts.deliver_user_reset_password_instructions(
37
+ user,
38
+ &url(~p"/users/reset_password/#{&1}")
39
+ )
40
+ end
41
+
42
+ info =
43
+ "If your email is in our system, you will receive instructions to reset your password shortly."
44
+
45
+ {:noreply,
46
+ socket
47
+ |> put_flash(:info, info)
48
+ |> redirect(to: ~p"/")}
49
+ end
50
+ end
lib/medical_transcription_web/live/user_login_live.ex ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserLoginLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ def render(assigns) do
5
+ ~H"""
6
+ <div class="mx-auto max-w-sm">
7
+ <.header class="text-center">
8
+ Sign in to account
9
+ <:subtitle>
10
+ Don't have an account?
11
+ <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
12
+ Sign up
13
+ </.link>
14
+ for an account now.
15
+ </:subtitle>
16
+ </.header>
17
+
18
+ <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
19
+ <.input field={@form[:email]} type="email" label="Email" required />
20
+ <.input field={@form[:password]} type="password" label="Password" required />
21
+
22
+ <:actions>
23
+ <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
24
+ <.link href={~p"/users/reset_password"} class="text-sm font-semibold">
25
+ Forgot your password?
26
+ </.link>
27
+ </:actions>
28
+ <:actions>
29
+ <.button phx-disable-with="Signing in..." class="w-full">
30
+ Sign in <span aria-hidden="true">→</span>
31
+ </.button>
32
+ </:actions>
33
+ </.simple_form>
34
+ </div>
35
+ """
36
+ end
37
+
38
+ def mount(_params, _session, socket) do
39
+ email = live_flash(socket.assigns.flash, :email)
40
+ form = to_form(%{"email" => email}, as: "user")
41
+ {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
42
+ end
43
+ end
lib/medical_transcription_web/live/user_registration_live.ex ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserRegistrationLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ alias MedicalTranscription.Accounts
5
+ alias MedicalTranscription.Accounts.User
6
+
7
+ def render(assigns) do
8
+ ~H"""
9
+ <div class="mx-auto max-w-sm">
10
+ <.header class="text-center">
11
+ Register for an account
12
+ <:subtitle>
13
+ Already registered?
14
+ <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
15
+ Sign in
16
+ </.link>
17
+ to your account now.
18
+ </:subtitle>
19
+ </.header>
20
+
21
+ <.simple_form
22
+ for={@form}
23
+ id="registration_form"
24
+ phx-submit="save"
25
+ phx-change="validate"
26
+ phx-trigger-action={@trigger_submit}
27
+ action={~p"/users/log_in?_action=registered"}
28
+ method="post"
29
+ >
30
+ <.error :if={@check_errors}>
31
+ Oops, something went wrong! Please check the errors below.
32
+ </.error>
33
+
34
+ <.input field={@form[:email]} type="email" label="Email" required />
35
+ <.input field={@form[:password]} type="password" label="Password" required />
36
+
37
+ <:actions>
38
+ <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
39
+ </:actions>
40
+ </.simple_form>
41
+ </div>
42
+ """
43
+ end
44
+
45
+ def mount(_params, _session, socket) do
46
+ changeset = Accounts.change_user_registration(%User{})
47
+
48
+ socket =
49
+ socket
50
+ |> assign(trigger_submit: false, check_errors: false)
51
+ |> assign_form(changeset)
52
+
53
+ {:ok, socket, temporary_assigns: [form: nil]}
54
+ end
55
+
56
+ def handle_event("save", %{"user" => user_params}, socket) do
57
+ case Accounts.register_user(user_params) do
58
+ {:ok, user} ->
59
+ {:ok, _} =
60
+ Accounts.deliver_user_confirmation_instructions(
61
+ user,
62
+ &url(~p"/users/confirm/#{&1}")
63
+ )
64
+
65
+ changeset = Accounts.change_user_registration(user)
66
+ {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
67
+
68
+ {:error, %Ecto.Changeset{} = changeset} ->
69
+ {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
70
+ end
71
+ end
72
+
73
+ def handle_event("validate", %{"user" => user_params}, socket) do
74
+ changeset = Accounts.change_user_registration(%User{}, user_params)
75
+ {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
76
+ end
77
+
78
+ defp assign_form(socket, %Ecto.Changeset{} = changeset) do
79
+ form = to_form(changeset, as: "user")
80
+
81
+ if changeset.valid? do
82
+ assign(socket, form: form, check_errors: false)
83
+ else
84
+ assign(socket, form: form)
85
+ end
86
+ end
87
+ end
lib/medical_transcription_web/live/user_reset_password_live.ex ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserResetPasswordLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ alias MedicalTranscription.Accounts
5
+
6
+ def render(assigns) do
7
+ ~H"""
8
+ <div class="mx-auto max-w-sm">
9
+ <.header class="text-center">Reset Password</.header>
10
+
11
+ <.simple_form
12
+ for={@form}
13
+ id="reset_password_form"
14
+ phx-submit="reset_password"
15
+ phx-change="validate"
16
+ >
17
+ <.error :if={@form.errors != []}>
18
+ Oops, something went wrong! Please check the errors below.
19
+ </.error>
20
+
21
+ <.input field={@form[:password]} type="password" label="New password" required />
22
+ <.input
23
+ field={@form[:password_confirmation]}
24
+ type="password"
25
+ label="Confirm new password"
26
+ required
27
+ />
28
+ <:actions>
29
+ <.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button>
30
+ </:actions>
31
+ </.simple_form>
32
+
33
+ <p class="text-center text-sm mt-4">
34
+ <.link href={~p"/users/register"}>Register</.link>
35
+ | <.link href={~p"/users/log_in"}>Log in</.link>
36
+ </p>
37
+ </div>
38
+ """
39
+ end
40
+
41
+ def mount(params, _session, socket) do
42
+ socket = assign_user_and_token(socket, params)
43
+
44
+ form_source =
45
+ case socket.assigns do
46
+ %{user: user} ->
47
+ Accounts.change_user_password(user)
48
+
49
+ _ ->
50
+ %{}
51
+ end
52
+
53
+ {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
54
+ end
55
+
56
+ # Do not log in the user after reset password to avoid a
57
+ # leaked token giving the user access to the account.
58
+ def handle_event("reset_password", %{"user" => user_params}, socket) do
59
+ case Accounts.reset_user_password(socket.assigns.user, user_params) do
60
+ {:ok, _} ->
61
+ {:noreply,
62
+ socket
63
+ |> put_flash(:info, "Password reset successfully.")
64
+ |> redirect(to: ~p"/users/log_in")}
65
+
66
+ {:error, changeset} ->
67
+ {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
68
+ end
69
+ end
70
+
71
+ def handle_event("validate", %{"user" => user_params}, socket) do
72
+ changeset = Accounts.change_user_password(socket.assigns.user, user_params)
73
+ {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
74
+ end
75
+
76
+ defp assign_user_and_token(socket, %{"token" => token}) do
77
+ if user = Accounts.get_user_by_reset_password_token(token) do
78
+ assign(socket, user: user, token: token)
79
+ else
80
+ socket
81
+ |> put_flash(:error, "Reset password link is invalid or it has expired.")
82
+ |> redirect(to: ~p"/")
83
+ end
84
+ end
85
+
86
+ defp assign_form(socket, %{} = source) do
87
+ assign(socket, :form, to_form(source, as: "user"))
88
+ end
89
+ end
lib/medical_transcription_web/live/user_settings_live.ex ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserSettingsLive do
2
+ use MedicalTranscriptionWeb, :live_view
3
+
4
+ alias MedicalTranscription.Accounts
5
+
6
+ def render(assigns) do
7
+ ~H"""
8
+ <.header class="text-center">
9
+ Account Settings
10
+ <:subtitle>Manage your account email address and password settings</:subtitle>
11
+ </.header>
12
+
13
+ <div class="space-y-12 divide-y">
14
+ <div>
15
+ <.simple_form
16
+ for={@email_form}
17
+ id="email_form"
18
+ phx-submit="update_email"
19
+ phx-change="validate_email"
20
+ >
21
+ <.input field={@email_form[:email]} type="email" label="Email" required />
22
+ <.input
23
+ field={@email_form[:current_password]}
24
+ name="current_password"
25
+ id="current_password_for_email"
26
+ type="password"
27
+ label="Current password"
28
+ value={@email_form_current_password}
29
+ required
30
+ />
31
+ <:actions>
32
+ <.button phx-disable-with="Changing...">Change Email</.button>
33
+ </:actions>
34
+ </.simple_form>
35
+ </div>
36
+ <div>
37
+ <.simple_form
38
+ for={@password_form}
39
+ id="password_form"
40
+ action={~p"/users/log_in?_action=password_updated"}
41
+ method="post"
42
+ phx-change="validate_password"
43
+ phx-submit="update_password"
44
+ phx-trigger-action={@trigger_submit}
45
+ >
46
+ <.input
47
+ field={@password_form[:email]}
48
+ type="hidden"
49
+ id="hidden_user_email"
50
+ value={@current_email}
51
+ />
52
+ <.input field={@password_form[:password]} type="password" label="New password" required />
53
+ <.input
54
+ field={@password_form[:password_confirmation]}
55
+ type="password"
56
+ label="Confirm new password"
57
+ />
58
+ <.input
59
+ field={@password_form[:current_password]}
60
+ name="current_password"
61
+ type="password"
62
+ label="Current password"
63
+ id="current_password_for_password"
64
+ value={@current_password}
65
+ required
66
+ />
67
+ <:actions>
68
+ <.button phx-disable-with="Changing...">Change Password</.button>
69
+ </:actions>
70
+ </.simple_form>
71
+ </div>
72
+ </div>
73
+ """
74
+ end
75
+
76
+ def mount(%{"token" => token}, _session, socket) do
77
+ socket =
78
+ case Accounts.update_user_email(socket.assigns.current_user, token) do
79
+ :ok ->
80
+ put_flash(socket, :info, "Email changed successfully.")
81
+
82
+ :error ->
83
+ put_flash(socket, :error, "Email change link is invalid or it has expired.")
84
+ end
85
+
86
+ {:ok, push_navigate(socket, to: ~p"/users/settings")}
87
+ end
88
+
89
+ def mount(_params, _session, socket) do
90
+ user = socket.assigns.current_user
91
+ email_changeset = Accounts.change_user_email(user)
92
+ password_changeset = Accounts.change_user_password(user)
93
+
94
+ socket =
95
+ socket
96
+ |> assign(:current_password, nil)
97
+ |> assign(:email_form_current_password, nil)
98
+ |> assign(:current_email, user.email)
99
+ |> assign(:email_form, to_form(email_changeset))
100
+ |> assign(:password_form, to_form(password_changeset))
101
+ |> assign(:trigger_submit, false)
102
+
103
+ {:ok, socket}
104
+ end
105
+
106
+ def handle_event("validate_email", params, socket) do
107
+ %{"current_password" => password, "user" => user_params} = params
108
+
109
+ email_form =
110
+ socket.assigns.current_user
111
+ |> Accounts.change_user_email(user_params)
112
+ |> Map.put(:action, :validate)
113
+ |> to_form()
114
+
115
+ {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
116
+ end
117
+
118
+ def handle_event("update_email", params, socket) do
119
+ %{"current_password" => password, "user" => user_params} = params
120
+ user = socket.assigns.current_user
121
+
122
+ case Accounts.apply_user_email(user, password, user_params) do
123
+ {:ok, applied_user} ->
124
+ Accounts.deliver_user_update_email_instructions(
125
+ applied_user,
126
+ user.email,
127
+ &url(~p"/users/settings/confirm_email/#{&1}")
128
+ )
129
+
130
+ info = "A link to confirm your email change has been sent to the new address."
131
+ {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
132
+
133
+ {:error, changeset} ->
134
+ {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
135
+ end
136
+ end
137
+
138
+ def handle_event("validate_password", params, socket) do
139
+ %{"current_password" => password, "user" => user_params} = params
140
+
141
+ password_form =
142
+ socket.assigns.current_user
143
+ |> Accounts.change_user_password(user_params)
144
+ |> Map.put(:action, :validate)
145
+ |> to_form()
146
+
147
+ {:noreply, assign(socket, password_form: password_form, current_password: password)}
148
+ end
149
+
150
+ def handle_event("update_password", params, socket) do
151
+ %{"current_password" => password, "user" => user_params} = params
152
+ user = socket.assigns.current_user
153
+
154
+ case Accounts.update_user_password(user, password, user_params) do
155
+ {:ok, user} ->
156
+ password_form =
157
+ user
158
+ |> Accounts.change_user_password(user_params)
159
+ |> to_form()
160
+
161
+ {:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
162
+
163
+ {:error, changeset} ->
164
+ {:noreply, assign(socket, password_form: to_form(changeset))}
165
+ end
166
+ end
167
+ end
lib/medical_transcription_web/router.ex CHANGED
@@ -1,5 +1,7 @@
1
  defmodule MedicalTranscriptionWeb.Router do
2
  use MedicalTranscriptionWeb, :router
 
 
3
  import PhoenixStorybook.Router
4
 
5
  pipeline :browser do
@@ -9,6 +11,7 @@ defmodule MedicalTranscriptionWeb.Router do
9
  plug :put_root_layout, html: {MedicalTranscriptionWeb.Layouts, :root}
10
  plug :protect_from_forgery
11
  plug :put_secure_browser_headers
 
12
  end
13
 
14
  pipeline :api do
@@ -52,4 +55,42 @@ defmodule MedicalTranscriptionWeb.Router do
52
  live_storybook("/storybook", backend_module: MedicalTranscriptionWeb.Storybook)
53
  end
54
  end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  end
 
1
  defmodule MedicalTranscriptionWeb.Router do
2
  use MedicalTranscriptionWeb, :router
3
+
4
+ import MedicalTranscriptionWeb.UserAuth
5
  import PhoenixStorybook.Router
6
 
7
  pipeline :browser do
 
11
  plug :put_root_layout, html: {MedicalTranscriptionWeb.Layouts, :root}
12
  plug :protect_from_forgery
13
  plug :put_secure_browser_headers
14
+ plug :fetch_current_user
15
  end
16
 
17
  pipeline :api do
 
55
  live_storybook("/storybook", backend_module: MedicalTranscriptionWeb.Storybook)
56
  end
57
  end
58
+
59
+ ## Authentication routes
60
+
61
+ scope "/", MedicalTranscriptionWeb do
62
+ pipe_through [:browser, :redirect_if_user_is_authenticated]
63
+
64
+ live_session :redirect_if_user_is_authenticated,
65
+ on_mount: [{MedicalTranscriptionWeb.UserAuth, :redirect_if_user_is_authenticated}] do
66
+ live "/users/register", UserRegistrationLive, :new
67
+ live "/users/log_in", UserLoginLive, :new
68
+ live "/users/reset_password", UserForgotPasswordLive, :new
69
+ live "/users/reset_password/:token", UserResetPasswordLive, :edit
70
+ end
71
+
72
+ post "/users/log_in", UserSessionController, :create
73
+ end
74
+
75
+ scope "/", MedicalTranscriptionWeb do
76
+ pipe_through [:browser, :require_authenticated_user]
77
+
78
+ live_session :require_authenticated_user,
79
+ on_mount: [{MedicalTranscriptionWeb.UserAuth, :ensure_authenticated}] do
80
+ live "/users/settings", UserSettingsLive, :edit
81
+ live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
82
+ end
83
+ end
84
+
85
+ scope "/", MedicalTranscriptionWeb do
86
+ pipe_through [:browser]
87
+
88
+ delete "/users/log_out", UserSessionController, :delete
89
+
90
+ live_session :current_user,
91
+ on_mount: [{MedicalTranscriptionWeb.UserAuth, :mount_current_user}] do
92
+ live "/users/confirm/:token", UserConfirmationLive, :edit
93
+ live "/users/confirm", UserConfirmationInstructionsLive, :new
94
+ end
95
+ end
96
  end
lib/medical_transcription_web/user_auth.ex ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserAuth do
2
+ use MedicalTranscriptionWeb, :verified_routes
3
+
4
+ import Plug.Conn
5
+ import Phoenix.Controller
6
+
7
+ alias MedicalTranscription.Accounts
8
+
9
+ # Make the remember me cookie valid for 60 days.
10
+ # If you want bump or reduce this value, also change
11
+ # the token expiry itself in UserToken.
12
+ @max_age 60 * 60 * 24 * 60
13
+ @remember_me_cookie "_medical_transcription_web_user_remember_me"
14
+ @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
15
+
16
+ @doc """
17
+ Logs the user in.
18
+
19
+ It renews the session ID and clears the whole session
20
+ to avoid fixation attacks. See the renew_session
21
+ function to customize this behaviour.
22
+
23
+ It also sets a `:live_socket_id` key in the session,
24
+ so LiveView sessions are identified and automatically
25
+ disconnected on log out. The line can be safely removed
26
+ if you are not using LiveView.
27
+ """
28
+ def log_in_user(conn, user, params \\ %{}) do
29
+ token = Accounts.generate_user_session_token(user)
30
+ user_return_to = get_session(conn, :user_return_to)
31
+
32
+ conn
33
+ |> renew_session()
34
+ |> put_token_in_session(token)
35
+ |> maybe_write_remember_me_cookie(token, params)
36
+ |> redirect(to: user_return_to || signed_in_path(conn))
37
+ end
38
+
39
+ defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
40
+ put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
41
+ end
42
+
43
+ defp maybe_write_remember_me_cookie(conn, _token, _params) do
44
+ conn
45
+ end
46
+
47
+ # This function renews the session ID and erases the whole
48
+ # session to avoid fixation attacks. If there is any data
49
+ # in the session you may want to preserve after log in/log out,
50
+ # you must explicitly fetch the session data before clearing
51
+ # and then immediately set it after clearing, for example:
52
+ #
53
+ # defp renew_session(conn) do
54
+ # preferred_locale = get_session(conn, :preferred_locale)
55
+ #
56
+ # conn
57
+ # |> configure_session(renew: true)
58
+ # |> clear_session()
59
+ # |> put_session(:preferred_locale, preferred_locale)
60
+ # end
61
+ #
62
+ defp renew_session(conn) do
63
+ conn
64
+ |> configure_session(renew: true)
65
+ |> clear_session()
66
+ end
67
+
68
+ @doc """
69
+ Logs the user out.
70
+
71
+ It clears all session data for safety. See renew_session.
72
+ """
73
+ def log_out_user(conn) do
74
+ user_token = get_session(conn, :user_token)
75
+ user_token && Accounts.delete_user_session_token(user_token)
76
+
77
+ if live_socket_id = get_session(conn, :live_socket_id) do
78
+ MedicalTranscriptionWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
79
+ end
80
+
81
+ conn
82
+ |> renew_session()
83
+ |> delete_resp_cookie(@remember_me_cookie)
84
+ |> redirect(to: ~p"/")
85
+ end
86
+
87
+ @doc """
88
+ Authenticates the user by looking into the session
89
+ and remember me token.
90
+ """
91
+ def fetch_current_user(conn, _opts) do
92
+ {user_token, conn} = ensure_user_token(conn)
93
+ user = user_token && Accounts.get_user_by_session_token(user_token)
94
+ assign(conn, :current_user, user)
95
+ end
96
+
97
+ defp ensure_user_token(conn) do
98
+ if token = get_session(conn, :user_token) do
99
+ {token, conn}
100
+ else
101
+ conn = fetch_cookies(conn, signed: [@remember_me_cookie])
102
+
103
+ if token = conn.cookies[@remember_me_cookie] do
104
+ {token, put_token_in_session(conn, token)}
105
+ else
106
+ {nil, conn}
107
+ end
108
+ end
109
+ end
110
+
111
+ @doc """
112
+ Handles mounting and authenticating the current_user in LiveViews.
113
+
114
+ ## `on_mount` arguments
115
+
116
+ * `:mount_current_user` - Assigns current_user
117
+ to socket assigns based on user_token, or nil if
118
+ there's no user_token or no matching user.
119
+
120
+ * `:ensure_authenticated` - Authenticates the user from the session,
121
+ and assigns the current_user to socket assigns based
122
+ on user_token.
123
+ Redirects to login page if there's no logged user.
124
+
125
+ * `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
126
+ Redirects to signed_in_path if there's a logged user.
127
+
128
+ ## Examples
129
+
130
+ Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
131
+ the current_user:
132
+
133
+ defmodule MedicalTranscriptionWeb.PageLive do
134
+ use MedicalTranscriptionWeb, :live_view
135
+
136
+ on_mount {MedicalTranscriptionWeb.UserAuth, :mount_current_user}
137
+ ...
138
+ end
139
+
140
+ Or use the `live_session` of your router to invoke the on_mount callback:
141
+
142
+ live_session :authenticated, on_mount: [{MedicalTranscriptionWeb.UserAuth, :ensure_authenticated}] do
143
+ live "/profile", ProfileLive, :index
144
+ end
145
+ """
146
+ def on_mount(:mount_current_user, _params, session, socket) do
147
+ {:cont, mount_current_user(socket, session)}
148
+ end
149
+
150
+ def on_mount(:ensure_authenticated, _params, session, socket) do
151
+ socket = mount_current_user(socket, session)
152
+
153
+ if socket.assigns.current_user do
154
+ {:cont, socket}
155
+ else
156
+ socket =
157
+ socket
158
+ |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
159
+ |> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
160
+
161
+ {:halt, socket}
162
+ end
163
+ end
164
+
165
+ def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
166
+ socket = mount_current_user(socket, session)
167
+
168
+ if socket.assigns.current_user do
169
+ {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
170
+ else
171
+ {:cont, socket}
172
+ end
173
+ end
174
+
175
+ defp mount_current_user(socket, session) do
176
+ Phoenix.Component.assign_new(socket, :current_user, fn ->
177
+ if user_token = session["user_token"] do
178
+ Accounts.get_user_by_session_token(user_token)
179
+ end
180
+ end)
181
+ end
182
+
183
+ @doc """
184
+ Used for routes that require the user to not be authenticated.
185
+ """
186
+ def redirect_if_user_is_authenticated(conn, _opts) do
187
+ if conn.assigns[:current_user] do
188
+ conn
189
+ |> redirect(to: signed_in_path(conn))
190
+ |> halt()
191
+ else
192
+ conn
193
+ end
194
+ end
195
+
196
+ @doc """
197
+ Used for routes that require the user to be authenticated.
198
+
199
+ If you want to enforce the user email is confirmed before
200
+ they use the application at all, here would be a good place.
201
+ """
202
+ def require_authenticated_user(conn, _opts) do
203
+ if conn.assigns[:current_user] do
204
+ conn
205
+ else
206
+ conn
207
+ |> put_flash(:error, "You must log in to access this page.")
208
+ |> maybe_store_return_to()
209
+ |> redirect(to: ~p"/users/log_in")
210
+ |> halt()
211
+ end
212
+ end
213
+
214
+ defp put_token_in_session(conn, token) do
215
+ conn
216
+ |> put_session(:user_token, token)
217
+ |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
218
+ end
219
+
220
+ defp maybe_store_return_to(%{method: "GET"} = conn) do
221
+ put_session(conn, :user_return_to, current_path(conn))
222
+ end
223
+
224
+ defp maybe_store_return_to(conn), do: conn
225
+
226
+ defp signed_in_path(_conn), do: ~p"/"
227
+ end
mix.exs CHANGED
@@ -32,6 +32,7 @@ defmodule MedicalTranscription.MixProject do
32
  # Type `mix help deps` for examples and options.
33
  defp deps do
34
  [
 
35
  {:phoenix, "~> 1.7.10"},
36
  {:phoenix_ecto, "~> 4.4"},
37
  {:ecto_sql, "~> 3.10"},
 
32
  # Type `mix help deps` for examples and options.
33
  defp deps do
34
  [
35
+ {:argon2_elixir, "~> 3.0"},
36
  {:phoenix, "~> 1.7.10"},
37
  {:phoenix_ecto, "~> 4.4"},
38
  {:ecto_sql, "~> 3.10"},
mix.lock CHANGED
@@ -1,4 +1,5 @@
1
  %{
 
2
  "audio_tagger": {:git, "https://github.com/headwayio/audio_tagger.git", "1d10f299cd2aa6e40608a16b6cfc80b80ef81b8f", []},
3
  "aws_signature": {:hex, :aws_signature, "0.3.1", "67f369094cbd55ffa2bbd8cc713ede14b195fcfb45c86665cd7c5ad010276148", [:rebar3], [], "hexpm", "50fc4dc1d1f7c2d0a8c63f455b3c66ecd74c1cf4c915c768a636f9227704a674"},
4
  "axon": {:hex, :axon, "0.6.0", "fd7560079581e4cedebaf0cd5f741d6ac3516d06f204ebaf1283b1093bf66ff6", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.6.0", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "204e7aeb50d231a30b25456adf17bfbaae33fe7c085e03793357ac3bf62fd853"},
@@ -10,6 +11,7 @@
10
  "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
11
  "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
12
  "coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"},
 
13
  "complex": {:hex, :complex, "0.5.0", "af2d2331ff6170b61bb738695e481b27a66780e18763e066ee2cd863d0b1dd92", [:mix], [], "hexpm", "2683bd3c184466cfb94fad74cbfddfaa94b860e27ad4ca1bffe3bff169d91ef1"},
14
  "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
15
  "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
 
1
  %{
2
+ "argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"},
3
  "audio_tagger": {:git, "https://github.com/headwayio/audio_tagger.git", "1d10f299cd2aa6e40608a16b6cfc80b80ef81b8f", []},
4
  "aws_signature": {:hex, :aws_signature, "0.3.1", "67f369094cbd55ffa2bbd8cc713ede14b195fcfb45c86665cd7c5ad010276148", [:rebar3], [], "hexpm", "50fc4dc1d1f7c2d0a8c63f455b3c66ecd74c1cf4c915c768a636f9227704a674"},
5
  "axon": {:hex, :axon, "0.6.0", "fd7560079581e4cedebaf0cd5f741d6ac3516d06f204ebaf1283b1093bf66ff6", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.6.0", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "204e7aeb50d231a30b25456adf17bfbaae33fe7c085e03793357ac3bf62fd853"},
 
11
  "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
12
  "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
13
  "coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"},
14
+ "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"},
15
  "complex": {:hex, :complex, "0.5.0", "af2d2331ff6170b61bb738695e481b27a66780e18763e066ee2cd863d0b1dd92", [:mix], [], "hexpm", "2683bd3c184466cfb94fad74cbfddfaa94b860e27ad4ca1bffe3bff169d91ef1"},
16
  "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
17
  "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
priv/repo/migrations/20240202163628_create_users_auth_tables.exs ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.Repo.Migrations.CreateUsersAuthTables do
2
+ use Ecto.Migration
3
+
4
+ def change do
5
+ execute "CREATE EXTENSION IF NOT EXISTS citext", ""
6
+
7
+ create table(:users, primary_key: false) do
8
+ add :id, :binary_id, primary_key: true
9
+ add :email, :citext, null: false
10
+ add :hashed_password, :string, null: false
11
+ add :confirmed_at, :naive_datetime
12
+ timestamps(type: :utc_datetime)
13
+ end
14
+
15
+ create unique_index(:users, [:email])
16
+
17
+ create table(:users_tokens, primary_key: false) do
18
+ add :id, :binary_id, primary_key: true
19
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
20
+ add :token, :binary, null: false
21
+ add :context, :string, null: false
22
+ add :sent_to, :string
23
+ timestamps(updated_at: false)
24
+ end
25
+
26
+ create index(:users_tokens, [:user_id])
27
+ create unique_index(:users_tokens, [:context, :token])
28
+ end
29
+ end
test/medical_transcription/accounts_test.exs ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.AccountsTest do
2
+ use MedicalTranscription.DataCase
3
+
4
+ alias MedicalTranscription.Accounts
5
+
6
+ import MedicalTranscription.AccountsFixtures
7
+ alias MedicalTranscription.Accounts.{User, UserToken}
8
+
9
+ describe "get_user_by_email/1" do
10
+ test "does not return the user if the email does not exist" do
11
+ refute Accounts.get_user_by_email("unknown@example.com")
12
+ end
13
+
14
+ test "returns the user if the email exists" do
15
+ %{id: id} = user = user_fixture()
16
+ assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
17
+ end
18
+ end
19
+
20
+ describe "get_user_by_email_and_password/2" do
21
+ test "does not return the user if the email does not exist" do
22
+ refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
23
+ end
24
+
25
+ test "does not return the user if the password is not valid" do
26
+ user = user_fixture()
27
+ refute Accounts.get_user_by_email_and_password(user.email, "invalid")
28
+ end
29
+
30
+ test "returns the user if the email and password are valid" do
31
+ %{id: id} = user = user_fixture()
32
+
33
+ assert %User{id: ^id} =
34
+ Accounts.get_user_by_email_and_password(user.email, valid_user_password())
35
+ end
36
+ end
37
+
38
+ describe "get_user!/1" do
39
+ test "raises if id is invalid" do
40
+ assert_raise Ecto.NoResultsError, fn ->
41
+ Accounts.get_user!("11111111-1111-1111-1111-111111111111")
42
+ end
43
+ end
44
+
45
+ test "returns the user with the given id" do
46
+ %{id: id} = user = user_fixture()
47
+ assert %User{id: ^id} = Accounts.get_user!(user.id)
48
+ end
49
+ end
50
+
51
+ describe "register_user/1" do
52
+ test "requires email and password to be set" do
53
+ {:error, changeset} = Accounts.register_user(%{})
54
+
55
+ assert %{
56
+ password: ["can't be blank"],
57
+ email: ["can't be blank"]
58
+ } = errors_on(changeset)
59
+ end
60
+
61
+ test "validates email and password when given" do
62
+ {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})
63
+
64
+ assert %{
65
+ email: ["must have the @ sign and no spaces"],
66
+ password: ["should be at least 12 character(s)"]
67
+ } = errors_on(changeset)
68
+ end
69
+
70
+ test "validates maximum values for email and password for security" do
71
+ too_long = String.duplicate("db", 100)
72
+ {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long})
73
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
74
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
75
+ end
76
+
77
+ test "validates email uniqueness" do
78
+ %{email: email} = user_fixture()
79
+ {:error, changeset} = Accounts.register_user(%{email: email})
80
+ assert "has already been taken" in errors_on(changeset).email
81
+
82
+ # Now try with the upper cased email too, to check that email case is ignored.
83
+ {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
84
+ assert "has already been taken" in errors_on(changeset).email
85
+ end
86
+
87
+ test "registers users with a hashed password" do
88
+ email = unique_user_email()
89
+ {:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
90
+ assert user.email == email
91
+ assert is_binary(user.hashed_password)
92
+ assert is_nil(user.confirmed_at)
93
+ assert is_nil(user.password)
94
+ end
95
+ end
96
+
97
+ describe "change_user_registration/2" do
98
+ test "returns a changeset" do
99
+ assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
100
+ assert changeset.required == [:password, :email]
101
+ end
102
+
103
+ test "allows fields to be set" do
104
+ email = unique_user_email()
105
+ password = valid_user_password()
106
+
107
+ changeset =
108
+ Accounts.change_user_registration(
109
+ %User{},
110
+ valid_user_attributes(email: email, password: password)
111
+ )
112
+
113
+ assert changeset.valid?
114
+ assert get_change(changeset, :email) == email
115
+ assert get_change(changeset, :password) == password
116
+ assert is_nil(get_change(changeset, :hashed_password))
117
+ end
118
+ end
119
+
120
+ describe "change_user_email/2" do
121
+ test "returns a user changeset" do
122
+ assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
123
+ assert changeset.required == [:email]
124
+ end
125
+ end
126
+
127
+ describe "apply_user_email/3" do
128
+ setup do
129
+ %{user: user_fixture()}
130
+ end
131
+
132
+ test "requires email to change", %{user: user} do
133
+ {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{})
134
+ assert %{email: ["did not change"]} = errors_on(changeset)
135
+ end
136
+
137
+ test "validates email", %{user: user} do
138
+ {:error, changeset} =
139
+ Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"})
140
+
141
+ assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
142
+ end
143
+
144
+ test "validates maximum value for email for security", %{user: user} do
145
+ too_long = String.duplicate("db", 100)
146
+
147
+ {:error, changeset} =
148
+ Accounts.apply_user_email(user, valid_user_password(), %{email: too_long})
149
+
150
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
151
+ end
152
+
153
+ test "validates email uniqueness", %{user: user} do
154
+ %{email: email} = user_fixture()
155
+ password = valid_user_password()
156
+
157
+ {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email})
158
+
159
+ assert "has already been taken" in errors_on(changeset).email
160
+ end
161
+
162
+ test "validates current password", %{user: user} do
163
+ {:error, changeset} =
164
+ Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()})
165
+
166
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
167
+ end
168
+
169
+ test "applies the email without persisting it", %{user: user} do
170
+ email = unique_user_email()
171
+ {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email})
172
+ assert user.email == email
173
+ assert Accounts.get_user!(user.id).email != email
174
+ end
175
+ end
176
+
177
+ describe "deliver_user_update_email_instructions/3" do
178
+ setup do
179
+ %{user: user_fixture()}
180
+ end
181
+
182
+ test "sends token through notification", %{user: user} do
183
+ token =
184
+ extract_user_token(fn url ->
185
+ Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
186
+ end)
187
+
188
+ {:ok, token} = Base.url_decode64(token, padding: false)
189
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
190
+ assert user_token.user_id == user.id
191
+ assert user_token.sent_to == user.email
192
+ assert user_token.context == "change:current@example.com"
193
+ end
194
+ end
195
+
196
+ describe "update_user_email/2" do
197
+ setup do
198
+ user = user_fixture()
199
+ email = unique_user_email()
200
+
201
+ token =
202
+ extract_user_token(fn url ->
203
+ Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
204
+ end)
205
+
206
+ %{user: user, token: token, email: email}
207
+ end
208
+
209
+ test "updates the email with a valid token", %{user: user, token: token, email: email} do
210
+ assert Accounts.update_user_email(user, token) == :ok
211
+ changed_user = Repo.get!(User, user.id)
212
+ assert changed_user.email != user.email
213
+ assert changed_user.email == email
214
+ assert changed_user.confirmed_at
215
+ assert changed_user.confirmed_at != user.confirmed_at
216
+ refute Repo.get_by(UserToken, user_id: user.id)
217
+ end
218
+
219
+ test "does not update email with invalid token", %{user: user} do
220
+ assert Accounts.update_user_email(user, "oops") == :error
221
+ assert Repo.get!(User, user.id).email == user.email
222
+ assert Repo.get_by(UserToken, user_id: user.id)
223
+ end
224
+
225
+ test "does not update email if user email changed", %{user: user, token: token} do
226
+ assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error
227
+ assert Repo.get!(User, user.id).email == user.email
228
+ assert Repo.get_by(UserToken, user_id: user.id)
229
+ end
230
+
231
+ test "does not update email if token expired", %{user: user, token: token} do
232
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
233
+ assert Accounts.update_user_email(user, token) == :error
234
+ assert Repo.get!(User, user.id).email == user.email
235
+ assert Repo.get_by(UserToken, user_id: user.id)
236
+ end
237
+ end
238
+
239
+ describe "change_user_password/2" do
240
+ test "returns a user changeset" do
241
+ assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
242
+ assert changeset.required == [:password]
243
+ end
244
+
245
+ test "allows fields to be set" do
246
+ changeset =
247
+ Accounts.change_user_password(%User{}, %{
248
+ "password" => "new valid password"
249
+ })
250
+
251
+ assert changeset.valid?
252
+ assert get_change(changeset, :password) == "new valid password"
253
+ assert is_nil(get_change(changeset, :hashed_password))
254
+ end
255
+ end
256
+
257
+ describe "update_user_password/3" do
258
+ setup do
259
+ %{user: user_fixture()}
260
+ end
261
+
262
+ test "validates password", %{user: user} do
263
+ {:error, changeset} =
264
+ Accounts.update_user_password(user, valid_user_password(), %{
265
+ password: "not valid",
266
+ password_confirmation: "another"
267
+ })
268
+
269
+ assert %{
270
+ password: ["should be at least 12 character(s)"],
271
+ password_confirmation: ["does not match password"]
272
+ } = errors_on(changeset)
273
+ end
274
+
275
+ test "validates maximum values for password for security", %{user: user} do
276
+ too_long = String.duplicate("db", 100)
277
+
278
+ {:error, changeset} =
279
+ Accounts.update_user_password(user, valid_user_password(), %{password: too_long})
280
+
281
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
282
+ end
283
+
284
+ test "validates current password", %{user: user} do
285
+ {:error, changeset} =
286
+ Accounts.update_user_password(user, "invalid", %{password: valid_user_password()})
287
+
288
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
289
+ end
290
+
291
+ test "updates the password", %{user: user} do
292
+ {:ok, user} =
293
+ Accounts.update_user_password(user, valid_user_password(), %{
294
+ password: "new valid password"
295
+ })
296
+
297
+ assert is_nil(user.password)
298
+ assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
299
+ end
300
+
301
+ test "deletes all tokens for the given user", %{user: user} do
302
+ _ = Accounts.generate_user_session_token(user)
303
+
304
+ {:ok, _} =
305
+ Accounts.update_user_password(user, valid_user_password(), %{
306
+ password: "new valid password"
307
+ })
308
+
309
+ refute Repo.get_by(UserToken, user_id: user.id)
310
+ end
311
+ end
312
+
313
+ describe "generate_user_session_token/1" do
314
+ setup do
315
+ %{user: user_fixture()}
316
+ end
317
+
318
+ test "generates a token", %{user: user} do
319
+ token = Accounts.generate_user_session_token(user)
320
+ assert user_token = Repo.get_by(UserToken, token: token)
321
+ assert user_token.context == "session"
322
+
323
+ # Creating the same token for another user should fail
324
+ assert_raise Ecto.ConstraintError, fn ->
325
+ Repo.insert!(%UserToken{
326
+ token: user_token.token,
327
+ user_id: user_fixture().id,
328
+ context: "session"
329
+ })
330
+ end
331
+ end
332
+ end
333
+
334
+ describe "get_user_by_session_token/1" do
335
+ setup do
336
+ user = user_fixture()
337
+ token = Accounts.generate_user_session_token(user)
338
+ %{user: user, token: token}
339
+ end
340
+
341
+ test "returns user by token", %{user: user, token: token} do
342
+ assert session_user = Accounts.get_user_by_session_token(token)
343
+ assert session_user.id == user.id
344
+ end
345
+
346
+ test "does not return user for invalid token" do
347
+ refute Accounts.get_user_by_session_token("oops")
348
+ end
349
+
350
+ test "does not return user for expired token", %{token: token} do
351
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
352
+ refute Accounts.get_user_by_session_token(token)
353
+ end
354
+ end
355
+
356
+ describe "delete_user_session_token/1" do
357
+ test "deletes the token" do
358
+ user = user_fixture()
359
+ token = Accounts.generate_user_session_token(user)
360
+ assert Accounts.delete_user_session_token(token) == :ok
361
+ refute Accounts.get_user_by_session_token(token)
362
+ end
363
+ end
364
+
365
+ describe "deliver_user_confirmation_instructions/2" do
366
+ setup do
367
+ %{user: user_fixture()}
368
+ end
369
+
370
+ test "sends token through notification", %{user: user} do
371
+ token =
372
+ extract_user_token(fn url ->
373
+ Accounts.deliver_user_confirmation_instructions(user, url)
374
+ end)
375
+
376
+ {:ok, token} = Base.url_decode64(token, padding: false)
377
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
378
+ assert user_token.user_id == user.id
379
+ assert user_token.sent_to == user.email
380
+ assert user_token.context == "confirm"
381
+ end
382
+ end
383
+
384
+ describe "confirm_user/1" do
385
+ setup do
386
+ user = user_fixture()
387
+
388
+ token =
389
+ extract_user_token(fn url ->
390
+ Accounts.deliver_user_confirmation_instructions(user, url)
391
+ end)
392
+
393
+ %{user: user, token: token}
394
+ end
395
+
396
+ test "confirms the email with a valid token", %{user: user, token: token} do
397
+ assert {:ok, confirmed_user} = Accounts.confirm_user(token)
398
+ assert confirmed_user.confirmed_at
399
+ assert confirmed_user.confirmed_at != user.confirmed_at
400
+ assert Repo.get!(User, user.id).confirmed_at
401
+ refute Repo.get_by(UserToken, user_id: user.id)
402
+ end
403
+
404
+ test "does not confirm with invalid token", %{user: user} do
405
+ assert Accounts.confirm_user("oops") == :error
406
+ refute Repo.get!(User, user.id).confirmed_at
407
+ assert Repo.get_by(UserToken, user_id: user.id)
408
+ end
409
+
410
+ test "does not confirm email if token expired", %{user: user, token: token} do
411
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
412
+ assert Accounts.confirm_user(token) == :error
413
+ refute Repo.get!(User, user.id).confirmed_at
414
+ assert Repo.get_by(UserToken, user_id: user.id)
415
+ end
416
+ end
417
+
418
+ describe "deliver_user_reset_password_instructions/2" do
419
+ setup do
420
+ %{user: user_fixture()}
421
+ end
422
+
423
+ test "sends token through notification", %{user: user} do
424
+ token =
425
+ extract_user_token(fn url ->
426
+ Accounts.deliver_user_reset_password_instructions(user, url)
427
+ end)
428
+
429
+ {:ok, token} = Base.url_decode64(token, padding: false)
430
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
431
+ assert user_token.user_id == user.id
432
+ assert user_token.sent_to == user.email
433
+ assert user_token.context == "reset_password"
434
+ end
435
+ end
436
+
437
+ describe "get_user_by_reset_password_token/1" do
438
+ setup do
439
+ user = user_fixture()
440
+
441
+ token =
442
+ extract_user_token(fn url ->
443
+ Accounts.deliver_user_reset_password_instructions(user, url)
444
+ end)
445
+
446
+ %{user: user, token: token}
447
+ end
448
+
449
+ test "returns the user with valid token", %{user: %{id: id}, token: token} do
450
+ assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token)
451
+ assert Repo.get_by(UserToken, user_id: id)
452
+ end
453
+
454
+ test "does not return the user with invalid token", %{user: user} do
455
+ refute Accounts.get_user_by_reset_password_token("oops")
456
+ assert Repo.get_by(UserToken, user_id: user.id)
457
+ end
458
+
459
+ test "does not return the user if token expired", %{user: user, token: token} do
460
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
461
+ refute Accounts.get_user_by_reset_password_token(token)
462
+ assert Repo.get_by(UserToken, user_id: user.id)
463
+ end
464
+ end
465
+
466
+ describe "reset_user_password/2" do
467
+ setup do
468
+ %{user: user_fixture()}
469
+ end
470
+
471
+ test "validates password", %{user: user} do
472
+ {:error, changeset} =
473
+ Accounts.reset_user_password(user, %{
474
+ password: "not valid",
475
+ password_confirmation: "another"
476
+ })
477
+
478
+ assert %{
479
+ password: ["should be at least 12 character(s)"],
480
+ password_confirmation: ["does not match password"]
481
+ } = errors_on(changeset)
482
+ end
483
+
484
+ test "validates maximum values for password for security", %{user: user} do
485
+ too_long = String.duplicate("db", 100)
486
+ {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long})
487
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
488
+ end
489
+
490
+ test "updates the password", %{user: user} do
491
+ {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"})
492
+ assert is_nil(updated_user.password)
493
+ assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
494
+ end
495
+
496
+ test "deletes all tokens for the given user", %{user: user} do
497
+ _ = Accounts.generate_user_session_token(user)
498
+ {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
499
+ refute Repo.get_by(UserToken, user_id: user.id)
500
+ end
501
+ end
502
+
503
+ describe "inspect/2 for the User module" do
504
+ test "does not include password" do
505
+ refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
506
+ end
507
+ end
508
+ end
test/medical_transcription_web/controllers/user_session_controller_test.exs ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserSessionControllerTest do
2
+ use MedicalTranscriptionWeb.ConnCase, async: true
3
+
4
+ import MedicalTranscription.AccountsFixtures
5
+
6
+ setup do
7
+ %{user: user_fixture()}
8
+ end
9
+
10
+ describe "POST /users/log_in" do
11
+ test "logs the user in", %{conn: conn, user: user} do
12
+ conn =
13
+ post(conn, ~p"/users/log_in", %{
14
+ "user" => %{"email" => user.email, "password" => valid_user_password()}
15
+ })
16
+
17
+ assert get_session(conn, :user_token)
18
+ assert redirected_to(conn) == ~p"/"
19
+
20
+ # Now do a logged in request and assert on the menu
21
+ conn = get(conn, ~p"/")
22
+ response = html_response(conn, 200)
23
+ assert response =~ user.email
24
+ assert response =~ ~p"/users/settings"
25
+ assert response =~ ~p"/users/log_out"
26
+ end
27
+
28
+ test "logs the user in with remember me", %{conn: conn, user: user} do
29
+ conn =
30
+ post(conn, ~p"/users/log_in", %{
31
+ "user" => %{
32
+ "email" => user.email,
33
+ "password" => valid_user_password(),
34
+ "remember_me" => "true"
35
+ }
36
+ })
37
+
38
+ assert conn.resp_cookies["_medical_transcription_web_user_remember_me"]
39
+ assert redirected_to(conn) == ~p"/"
40
+ end
41
+
42
+ test "logs the user in with return to", %{conn: conn, user: user} do
43
+ conn =
44
+ conn
45
+ |> init_test_session(user_return_to: "/foo/bar")
46
+ |> post(~p"/users/log_in", %{
47
+ "user" => %{
48
+ "email" => user.email,
49
+ "password" => valid_user_password()
50
+ }
51
+ })
52
+
53
+ assert redirected_to(conn) == "/foo/bar"
54
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
55
+ end
56
+
57
+ test "login following registration", %{conn: conn, user: user} do
58
+ conn =
59
+ conn
60
+ |> post(~p"/users/log_in", %{
61
+ "_action" => "registered",
62
+ "user" => %{
63
+ "email" => user.email,
64
+ "password" => valid_user_password()
65
+ }
66
+ })
67
+
68
+ assert redirected_to(conn) == ~p"/"
69
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully"
70
+ end
71
+
72
+ test "login following password update", %{conn: conn, user: user} do
73
+ conn =
74
+ conn
75
+ |> post(~p"/users/log_in", %{
76
+ "_action" => "password_updated",
77
+ "user" => %{
78
+ "email" => user.email,
79
+ "password" => valid_user_password()
80
+ }
81
+ })
82
+
83
+ assert redirected_to(conn) == ~p"/users/settings"
84
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully"
85
+ end
86
+
87
+ test "redirects to login page with invalid credentials", %{conn: conn} do
88
+ conn =
89
+ post(conn, ~p"/users/log_in", %{
90
+ "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"}
91
+ })
92
+
93
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
94
+ assert redirected_to(conn) == ~p"/users/log_in"
95
+ end
96
+ end
97
+
98
+ describe "DELETE /users/log_out" do
99
+ test "logs the user out", %{conn: conn, user: user} do
100
+ conn = conn |> log_in_user(user) |> delete(~p"/users/log_out")
101
+ assert redirected_to(conn) == ~p"/"
102
+ refute get_session(conn, :user_token)
103
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
104
+ end
105
+
106
+ test "succeeds even if the user is not logged in", %{conn: conn} do
107
+ conn = delete(conn, ~p"/users/log_out")
108
+ assert redirected_to(conn) == ~p"/"
109
+ refute get_session(conn, :user_token)
110
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
111
+ end
112
+ end
113
+ end
test/medical_transcription_web/live/user_confirmation_instructions_live_test.exs ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserConfirmationInstructionsLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ import Phoenix.LiveViewTest
5
+ import MedicalTranscription.AccountsFixtures
6
+
7
+ alias MedicalTranscription.Accounts
8
+ alias MedicalTranscription.Repo
9
+
10
+ setup do
11
+ %{user: user_fixture()}
12
+ end
13
+
14
+ describe "Resend confirmation" do
15
+ test "renders the resend confirmation page", %{conn: conn} do
16
+ {:ok, _lv, html} = live(conn, ~p"/users/confirm")
17
+ assert html =~ "Resend confirmation instructions"
18
+ end
19
+
20
+ test "sends a new confirmation token", %{conn: conn, user: user} do
21
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm")
22
+
23
+ {:ok, conn} =
24
+ lv
25
+ |> form("#resend_confirmation_form", user: %{email: user.email})
26
+ |> render_submit()
27
+ |> follow_redirect(conn, ~p"/")
28
+
29
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
30
+ "If your email is in our system"
31
+
32
+ assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
33
+ end
34
+
35
+ test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do
36
+ Repo.update!(Accounts.User.confirm_changeset(user))
37
+
38
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm")
39
+
40
+ {:ok, conn} =
41
+ lv
42
+ |> form("#resend_confirmation_form", user: %{email: user.email})
43
+ |> render_submit()
44
+ |> follow_redirect(conn, ~p"/")
45
+
46
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
47
+ "If your email is in our system"
48
+
49
+ refute Repo.get_by(Accounts.UserToken, user_id: user.id)
50
+ end
51
+
52
+ test "does not send confirmation token if email is invalid", %{conn: conn} do
53
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm")
54
+
55
+ {:ok, conn} =
56
+ lv
57
+ |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"})
58
+ |> render_submit()
59
+ |> follow_redirect(conn, ~p"/")
60
+
61
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
62
+ "If your email is in our system"
63
+
64
+ assert Repo.all(Accounts.UserToken) == []
65
+ end
66
+ end
67
+ end
test/medical_transcription_web/live/user_confirmation_live_test.exs ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserConfirmationLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ import Phoenix.LiveViewTest
5
+ import MedicalTranscription.AccountsFixtures
6
+
7
+ alias MedicalTranscription.Accounts
8
+ alias MedicalTranscription.Repo
9
+
10
+ setup do
11
+ %{user: user_fixture()}
12
+ end
13
+
14
+ describe "Confirm user" do
15
+ test "renders confirmation page", %{conn: conn} do
16
+ {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token")
17
+ assert html =~ "Confirm Account"
18
+ end
19
+
20
+ test "confirms the given token once", %{conn: conn, user: user} do
21
+ token =
22
+ extract_user_token(fn url ->
23
+ Accounts.deliver_user_confirmation_instructions(user, url)
24
+ end)
25
+
26
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
27
+
28
+ result =
29
+ lv
30
+ |> form("#confirmation_form")
31
+ |> render_submit()
32
+ |> follow_redirect(conn, "/")
33
+
34
+ assert {:ok, conn} = result
35
+
36
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
37
+ "User confirmed successfully"
38
+
39
+ assert Accounts.get_user!(user.id).confirmed_at
40
+ refute get_session(conn, :user_token)
41
+ assert Repo.all(Accounts.UserToken) == []
42
+
43
+ # when not logged in
44
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
45
+
46
+ result =
47
+ lv
48
+ |> form("#confirmation_form")
49
+ |> render_submit()
50
+ |> follow_redirect(conn, "/")
51
+
52
+ assert {:ok, conn} = result
53
+
54
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
55
+ "User confirmation link is invalid or it has expired"
56
+
57
+ # when logged in
58
+ conn =
59
+ build_conn()
60
+ |> log_in_user(user)
61
+
62
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
63
+
64
+ result =
65
+ lv
66
+ |> form("#confirmation_form")
67
+ |> render_submit()
68
+ |> follow_redirect(conn, "/")
69
+
70
+ assert {:ok, conn} = result
71
+ refute Phoenix.Flash.get(conn.assigns.flash, :error)
72
+ end
73
+
74
+ test "does not confirm email with invalid token", %{conn: conn, user: user} do
75
+ {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token")
76
+
77
+ {:ok, conn} =
78
+ lv
79
+ |> form("#confirmation_form")
80
+ |> render_submit()
81
+ |> follow_redirect(conn, ~p"/")
82
+
83
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
84
+ "User confirmation link is invalid or it has expired"
85
+
86
+ refute Accounts.get_user!(user.id).confirmed_at
87
+ end
88
+ end
89
+ end
test/medical_transcription_web/live/user_forgot_password_live_test.exs ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserForgotPasswordLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ import Phoenix.LiveViewTest
5
+ import MedicalTranscription.AccountsFixtures
6
+
7
+ alias MedicalTranscription.Accounts
8
+ alias MedicalTranscription.Repo
9
+
10
+ describe "Forgot password page" do
11
+ test "renders email page", %{conn: conn} do
12
+ {:ok, lv, html} = live(conn, ~p"/users/reset_password")
13
+
14
+ assert html =~ "Forgot your password?"
15
+ assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register")
16
+ assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in")
17
+ end
18
+
19
+ test "redirects if already logged in", %{conn: conn} do
20
+ result =
21
+ conn
22
+ |> log_in_user(user_fixture())
23
+ |> live(~p"/users/reset_password")
24
+ |> follow_redirect(conn, ~p"/")
25
+
26
+ assert {:ok, _conn} = result
27
+ end
28
+ end
29
+
30
+ describe "Reset link" do
31
+ setup do
32
+ %{user: user_fixture()}
33
+ end
34
+
35
+ test "sends a new reset password token", %{conn: conn, user: user} do
36
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password")
37
+
38
+ {:ok, conn} =
39
+ lv
40
+ |> form("#reset_password_form", user: %{"email" => user.email})
41
+ |> render_submit()
42
+ |> follow_redirect(conn, "/")
43
+
44
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
45
+
46
+ assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context ==
47
+ "reset_password"
48
+ end
49
+
50
+ test "does not send reset password token if email is invalid", %{conn: conn} do
51
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password")
52
+
53
+ {:ok, conn} =
54
+ lv
55
+ |> form("#reset_password_form", user: %{"email" => "unknown@example.com"})
56
+ |> render_submit()
57
+ |> follow_redirect(conn, "/")
58
+
59
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
60
+ assert Repo.all(Accounts.UserToken) == []
61
+ end
62
+ end
63
+ end
test/medical_transcription_web/live/user_login_live_test.exs ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserLoginLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ import Phoenix.LiveViewTest
5
+ import MedicalTranscription.AccountsFixtures
6
+
7
+ describe "Log in page" do
8
+ test "renders log in page", %{conn: conn} do
9
+ {:ok, _lv, html} = live(conn, ~p"/users/log_in")
10
+
11
+ assert html =~ "Log in"
12
+ assert html =~ "Register"
13
+ assert html =~ "Forgot your password?"
14
+ end
15
+
16
+ test "redirects if already logged in", %{conn: conn} do
17
+ result =
18
+ conn
19
+ |> log_in_user(user_fixture())
20
+ |> live(~p"/users/log_in")
21
+ |> follow_redirect(conn, "/")
22
+
23
+ assert {:ok, _conn} = result
24
+ end
25
+ end
26
+
27
+ describe "user login" do
28
+ test "redirects if user login with valid credentials", %{conn: conn} do
29
+ password = "123456789abcd"
30
+ user = user_fixture(%{password: password})
31
+
32
+ {:ok, lv, _html} = live(conn, ~p"/users/log_in")
33
+
34
+ form =
35
+ form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true})
36
+
37
+ conn = submit_form(form, conn)
38
+
39
+ assert redirected_to(conn) == ~p"/"
40
+ end
41
+
42
+ test "redirects to login page with a flash error if there are no valid credentials", %{
43
+ conn: conn
44
+ } do
45
+ {:ok, lv, _html} = live(conn, ~p"/users/log_in")
46
+
47
+ form =
48
+ form(lv, "#login_form",
49
+ user: %{email: "test@email.com", password: "123456", remember_me: true}
50
+ )
51
+
52
+ conn = submit_form(form, conn)
53
+
54
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
55
+
56
+ assert redirected_to(conn) == "/users/log_in"
57
+ end
58
+ end
59
+
60
+ describe "login navigation" do
61
+ test "redirects to registration page when the Register button is clicked", %{conn: conn} do
62
+ {:ok, lv, _html} = live(conn, ~p"/users/log_in")
63
+
64
+ {:ok, _login_live, login_html} =
65
+ lv
66
+ |> element(~s|main a:fl-contains("Sign up")|)
67
+ |> render_click()
68
+ |> follow_redirect(conn, ~p"/users/register")
69
+
70
+ assert login_html =~ "Register"
71
+ end
72
+
73
+ test "redirects to forgot password page when the Forgot Password button is clicked", %{
74
+ conn: conn
75
+ } do
76
+ {:ok, lv, _html} = live(conn, ~p"/users/log_in")
77
+
78
+ {:ok, conn} =
79
+ lv
80
+ |> element(~s|main a:fl-contains("Forgot your password?")|)
81
+ |> render_click()
82
+ |> follow_redirect(conn, ~p"/users/reset_password")
83
+
84
+ assert conn.resp_body =~ "Forgot your password?"
85
+ end
86
+ end
87
+ end
test/medical_transcription_web/live/user_registration_live_test.exs ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserRegistrationLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ import Phoenix.LiveViewTest
5
+ import MedicalTranscription.AccountsFixtures
6
+
7
+ describe "Registration page" do
8
+ test "renders registration page", %{conn: conn} do
9
+ {:ok, _lv, html} = live(conn, ~p"/users/register")
10
+
11
+ assert html =~ "Register"
12
+ assert html =~ "Log in"
13
+ end
14
+
15
+ test "redirects if already logged in", %{conn: conn} do
16
+ result =
17
+ conn
18
+ |> log_in_user(user_fixture())
19
+ |> live(~p"/users/register")
20
+ |> follow_redirect(conn, "/")
21
+
22
+ assert {:ok, _conn} = result
23
+ end
24
+
25
+ test "renders errors for invalid data", %{conn: conn} do
26
+ {:ok, lv, _html} = live(conn, ~p"/users/register")
27
+
28
+ result =
29
+ lv
30
+ |> element("#registration_form")
31
+ |> render_change(user: %{"email" => "with spaces", "password" => "too short"})
32
+
33
+ assert result =~ "Register"
34
+ assert result =~ "must have the @ sign and no spaces"
35
+ assert result =~ "should be at least 12 character"
36
+ end
37
+ end
38
+
39
+ describe "register user" do
40
+ test "creates account and logs the user in", %{conn: conn} do
41
+ {:ok, lv, _html} = live(conn, ~p"/users/register")
42
+
43
+ email = unique_user_email()
44
+ form = form(lv, "#registration_form", user: valid_user_attributes(email: email))
45
+ render_submit(form)
46
+ conn = follow_trigger_action(form, conn)
47
+
48
+ assert redirected_to(conn) == ~p"/"
49
+
50
+ # Now do a logged in request and assert on the menu
51
+ conn = get(conn, "/")
52
+ response = html_response(conn, 200)
53
+ assert response =~ email
54
+ assert response =~ "Settings"
55
+ assert response =~ "Log out"
56
+ end
57
+
58
+ test "renders errors for duplicated email", %{conn: conn} do
59
+ {:ok, lv, _html} = live(conn, ~p"/users/register")
60
+
61
+ user = user_fixture(%{email: "test@email.com"})
62
+
63
+ result =
64
+ lv
65
+ |> form("#registration_form",
66
+ user: %{"email" => user.email, "password" => "valid_password"}
67
+ )
68
+ |> render_submit()
69
+
70
+ assert result =~ "has already been taken"
71
+ end
72
+ end
73
+
74
+ describe "registration navigation" do
75
+ test "redirects to login page when the Log in button is clicked", %{conn: conn} do
76
+ {:ok, lv, _html} = live(conn, ~p"/users/register")
77
+
78
+ {:ok, _login_live, login_html} =
79
+ lv
80
+ |> element(~s|main a:fl-contains("Sign in")|)
81
+ |> render_click()
82
+ |> follow_redirect(conn, ~p"/users/log_in")
83
+
84
+ assert login_html =~ "Log in"
85
+ end
86
+ end
87
+ end
test/medical_transcription_web/live/user_reset_password_live_test.exs ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserResetPasswordLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ import Phoenix.LiveViewTest
5
+ import MedicalTranscription.AccountsFixtures
6
+
7
+ alias MedicalTranscription.Accounts
8
+
9
+ setup do
10
+ user = user_fixture()
11
+
12
+ token =
13
+ extract_user_token(fn url ->
14
+ Accounts.deliver_user_reset_password_instructions(user, url)
15
+ end)
16
+
17
+ %{token: token, user: user}
18
+ end
19
+
20
+ describe "Reset password page" do
21
+ test "renders reset password with valid token", %{conn: conn, token: token} do
22
+ {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}")
23
+
24
+ assert html =~ "Reset Password"
25
+ end
26
+
27
+ test "does not render reset password with invalid token", %{conn: conn} do
28
+ {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid")
29
+
30
+ assert to == %{
31
+ flash: %{"error" => "Reset password link is invalid or it has expired."},
32
+ to: ~p"/"
33
+ }
34
+ end
35
+
36
+ test "renders errors for invalid data", %{conn: conn, token: token} do
37
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
38
+
39
+ result =
40
+ lv
41
+ |> element("#reset_password_form")
42
+ |> render_change(
43
+ user: %{"password" => "secret12", "password_confirmation" => "secret123456"}
44
+ )
45
+
46
+ assert result =~ "should be at least 12 character"
47
+ assert result =~ "does not match password"
48
+ end
49
+ end
50
+
51
+ describe "Reset Password" do
52
+ test "resets password once", %{conn: conn, token: token, user: user} do
53
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
54
+
55
+ {:ok, conn} =
56
+ lv
57
+ |> form("#reset_password_form",
58
+ user: %{
59
+ "password" => "new valid password",
60
+ "password_confirmation" => "new valid password"
61
+ }
62
+ )
63
+ |> render_submit()
64
+ |> follow_redirect(conn, ~p"/users/log_in")
65
+
66
+ refute get_session(conn, :user_token)
67
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
68
+ assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
69
+ end
70
+
71
+ test "does not reset password on invalid data", %{conn: conn, token: token} do
72
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
73
+
74
+ result =
75
+ lv
76
+ |> form("#reset_password_form",
77
+ user: %{
78
+ "password" => "too short",
79
+ "password_confirmation" => "does not match"
80
+ }
81
+ )
82
+ |> render_submit()
83
+
84
+ assert result =~ "Reset Password"
85
+ assert result =~ "should be at least 12 character(s)"
86
+ assert result =~ "does not match password"
87
+ end
88
+ end
89
+
90
+ describe "Reset password navigation" do
91
+ test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do
92
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
93
+
94
+ {:ok, conn} =
95
+ lv
96
+ |> element(~s|main a:fl-contains("Log in")|)
97
+ |> render_click()
98
+ |> follow_redirect(conn, ~p"/users/log_in")
99
+
100
+ assert conn.resp_body =~ "Log in"
101
+ end
102
+
103
+ test "redirects to password reset page when the Register button is clicked", %{
104
+ conn: conn,
105
+ token: token
106
+ } do
107
+ {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
108
+
109
+ {:ok, conn} =
110
+ lv
111
+ |> element(~s|main a:fl-contains("Register")|)
112
+ |> render_click()
113
+ |> follow_redirect(conn, ~p"/users/register")
114
+
115
+ assert conn.resp_body =~ "Register"
116
+ end
117
+ end
118
+ end
test/medical_transcription_web/live/user_settings_live_test.exs ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserSettingsLiveTest do
2
+ use MedicalTranscriptionWeb.ConnCase
3
+
4
+ alias MedicalTranscription.Accounts
5
+ import Phoenix.LiveViewTest
6
+ import MedicalTranscription.AccountsFixtures
7
+
8
+ describe "Settings page" do
9
+ test "renders settings page", %{conn: conn} do
10
+ {:ok, _lv, html} =
11
+ conn
12
+ |> log_in_user(user_fixture())
13
+ |> live(~p"/users/settings")
14
+
15
+ assert html =~ "Change Email"
16
+ assert html =~ "Change Password"
17
+ end
18
+
19
+ test "redirects if user is not logged in", %{conn: conn} do
20
+ assert {:error, redirect} = live(conn, ~p"/users/settings")
21
+
22
+ assert {:redirect, %{to: path, flash: flash}} = redirect
23
+ assert path == ~p"/users/log_in"
24
+ assert %{"error" => "You must log in to access this page."} = flash
25
+ end
26
+ end
27
+
28
+ describe "update email form" do
29
+ setup %{conn: conn} do
30
+ password = valid_user_password()
31
+ user = user_fixture(%{password: password})
32
+ %{conn: log_in_user(conn, user), user: user, password: password}
33
+ end
34
+
35
+ test "updates the user email", %{conn: conn, password: password, user: user} do
36
+ new_email = unique_user_email()
37
+
38
+ {:ok, lv, _html} = live(conn, ~p"/users/settings")
39
+
40
+ result =
41
+ lv
42
+ |> form("#email_form", %{
43
+ "current_password" => password,
44
+ "user" => %{"email" => new_email}
45
+ })
46
+ |> render_submit()
47
+
48
+ assert result =~ "A link to confirm your email"
49
+ assert Accounts.get_user_by_email(user.email)
50
+ end
51
+
52
+ test "renders errors with invalid data (phx-change)", %{conn: conn} do
53
+ {:ok, lv, _html} = live(conn, ~p"/users/settings")
54
+
55
+ result =
56
+ lv
57
+ |> element("#email_form")
58
+ |> render_change(%{
59
+ "action" => "update_email",
60
+ "current_password" => "invalid",
61
+ "user" => %{"email" => "with spaces"}
62
+ })
63
+
64
+ assert result =~ "Change Email"
65
+ assert result =~ "must have the @ sign and no spaces"
66
+ end
67
+
68
+ test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do
69
+ {:ok, lv, _html} = live(conn, ~p"/users/settings")
70
+
71
+ result =
72
+ lv
73
+ |> form("#email_form", %{
74
+ "current_password" => "invalid",
75
+ "user" => %{"email" => user.email}
76
+ })
77
+ |> render_submit()
78
+
79
+ assert result =~ "Change Email"
80
+ assert result =~ "did not change"
81
+ assert result =~ "is not valid"
82
+ end
83
+ end
84
+
85
+ describe "update password form" do
86
+ setup %{conn: conn} do
87
+ password = valid_user_password()
88
+ user = user_fixture(%{password: password})
89
+ %{conn: log_in_user(conn, user), user: user, password: password}
90
+ end
91
+
92
+ test "updates the user password", %{conn: conn, user: user, password: password} do
93
+ new_password = valid_user_password()
94
+
95
+ {:ok, lv, _html} = live(conn, ~p"/users/settings")
96
+
97
+ form =
98
+ form(lv, "#password_form", %{
99
+ "current_password" => password,
100
+ "user" => %{
101
+ "email" => user.email,
102
+ "password" => new_password,
103
+ "password_confirmation" => new_password
104
+ }
105
+ })
106
+
107
+ render_submit(form)
108
+
109
+ new_password_conn = follow_trigger_action(form, conn)
110
+
111
+ assert redirected_to(new_password_conn) == ~p"/users/settings"
112
+
113
+ assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
114
+
115
+ assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
116
+ "Password updated successfully"
117
+
118
+ assert Accounts.get_user_by_email_and_password(user.email, new_password)
119
+ end
120
+
121
+ test "renders errors with invalid data (phx-change)", %{conn: conn} do
122
+ {:ok, lv, _html} = live(conn, ~p"/users/settings")
123
+
124
+ result =
125
+ lv
126
+ |> element("#password_form")
127
+ |> render_change(%{
128
+ "current_password" => "invalid",
129
+ "user" => %{
130
+ "password" => "too short",
131
+ "password_confirmation" => "does not match"
132
+ }
133
+ })
134
+
135
+ assert result =~ "Change Password"
136
+ assert result =~ "should be at least 12 character(s)"
137
+ assert result =~ "does not match password"
138
+ end
139
+
140
+ test "renders errors with invalid data (phx-submit)", %{conn: conn} do
141
+ {:ok, lv, _html} = live(conn, ~p"/users/settings")
142
+
143
+ result =
144
+ lv
145
+ |> form("#password_form", %{
146
+ "current_password" => "invalid",
147
+ "user" => %{
148
+ "password" => "too short",
149
+ "password_confirmation" => "does not match"
150
+ }
151
+ })
152
+ |> render_submit()
153
+
154
+ assert result =~ "Change Password"
155
+ assert result =~ "should be at least 12 character(s)"
156
+ assert result =~ "does not match password"
157
+ assert result =~ "is not valid"
158
+ end
159
+ end
160
+
161
+ describe "confirm email" do
162
+ setup %{conn: conn} do
163
+ user = user_fixture()
164
+ email = unique_user_email()
165
+
166
+ token =
167
+ extract_user_token(fn url ->
168
+ Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
169
+ end)
170
+
171
+ %{conn: log_in_user(conn, user), token: token, email: email, user: user}
172
+ end
173
+
174
+ test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
175
+ {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
176
+
177
+ assert {:live_redirect, %{to: path, flash: flash}} = redirect
178
+ assert path == ~p"/users/settings"
179
+ assert %{"info" => message} = flash
180
+ assert message == "Email changed successfully."
181
+ refute Accounts.get_user_by_email(user.email)
182
+ assert Accounts.get_user_by_email(email)
183
+
184
+ # use confirm token again
185
+ {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
186
+ assert {:live_redirect, %{to: path, flash: flash}} = redirect
187
+ assert path == ~p"/users/settings"
188
+ assert %{"error" => message} = flash
189
+ assert message == "Email change link is invalid or it has expired."
190
+ end
191
+
192
+ test "does not update email with invalid token", %{conn: conn, user: user} do
193
+ {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops")
194
+ assert {:live_redirect, %{to: path, flash: flash}} = redirect
195
+ assert path == ~p"/users/settings"
196
+ assert %{"error" => message} = flash
197
+ assert message == "Email change link is invalid or it has expired."
198
+ assert Accounts.get_user_by_email(user.email)
199
+ end
200
+
201
+ test "redirects if user is not logged in", %{token: token} do
202
+ conn = build_conn()
203
+ {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
204
+ assert {:redirect, %{to: path, flash: flash}} = redirect
205
+ assert path == ~p"/users/log_in"
206
+ assert %{"error" => message} = flash
207
+ assert message == "You must log in to access this page."
208
+ end
209
+ end
210
+ end
test/medical_transcription_web/user_auth_test.exs ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscriptionWeb.UserAuthTest do
2
+ use MedicalTranscriptionWeb.ConnCase, async: true
3
+
4
+ alias Phoenix.LiveView
5
+ alias MedicalTranscription.Accounts
6
+ alias MedicalTranscriptionWeb.UserAuth
7
+ import MedicalTranscription.AccountsFixtures
8
+
9
+ @remember_me_cookie "_medical_transcription_web_user_remember_me"
10
+
11
+ setup %{conn: conn} do
12
+ conn =
13
+ conn
14
+ |> Map.replace!(:secret_key_base, MedicalTranscriptionWeb.Endpoint.config(:secret_key_base))
15
+ |> init_test_session(%{})
16
+
17
+ %{user: user_fixture(), conn: conn}
18
+ end
19
+
20
+ describe "log_in_user/3" do
21
+ test "stores the user token in the session", %{conn: conn, user: user} do
22
+ conn = UserAuth.log_in_user(conn, user)
23
+ assert token = get_session(conn, :user_token)
24
+ assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
25
+ assert redirected_to(conn) == ~p"/"
26
+ assert Accounts.get_user_by_session_token(token)
27
+ end
28
+
29
+ test "clears everything previously stored in the session", %{conn: conn, user: user} do
30
+ conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
31
+ refute get_session(conn, :to_be_removed)
32
+ end
33
+
34
+ test "redirects to the configured path", %{conn: conn, user: user} do
35
+ conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
36
+ assert redirected_to(conn) == "/hello"
37
+ end
38
+
39
+ test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
40
+ conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
41
+ assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
42
+
43
+ assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
44
+ assert signed_token != get_session(conn, :user_token)
45
+ assert max_age == 5_184_000
46
+ end
47
+ end
48
+
49
+ describe "logout_user/1" do
50
+ test "erases session and cookies", %{conn: conn, user: user} do
51
+ user_token = Accounts.generate_user_session_token(user)
52
+
53
+ conn =
54
+ conn
55
+ |> put_session(:user_token, user_token)
56
+ |> put_req_cookie(@remember_me_cookie, user_token)
57
+ |> fetch_cookies()
58
+ |> UserAuth.log_out_user()
59
+
60
+ refute get_session(conn, :user_token)
61
+ refute conn.cookies[@remember_me_cookie]
62
+ assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
63
+ assert redirected_to(conn) == ~p"/"
64
+ refute Accounts.get_user_by_session_token(user_token)
65
+ end
66
+
67
+ test "broadcasts to the given live_socket_id", %{conn: conn} do
68
+ live_socket_id = "users_sessions:abcdef-token"
69
+ MedicalTranscriptionWeb.Endpoint.subscribe(live_socket_id)
70
+
71
+ conn
72
+ |> put_session(:live_socket_id, live_socket_id)
73
+ |> UserAuth.log_out_user()
74
+
75
+ assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
76
+ end
77
+
78
+ test "works even if user is already logged out", %{conn: conn} do
79
+ conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
80
+ refute get_session(conn, :user_token)
81
+ assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
82
+ assert redirected_to(conn) == ~p"/"
83
+ end
84
+ end
85
+
86
+ describe "fetch_current_user/2" do
87
+ test "authenticates user from session", %{conn: conn, user: user} do
88
+ user_token = Accounts.generate_user_session_token(user)
89
+ conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
90
+ assert conn.assigns.current_user.id == user.id
91
+ end
92
+
93
+ test "authenticates user from cookies", %{conn: conn, user: user} do
94
+ logged_in_conn =
95
+ conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
96
+
97
+ user_token = logged_in_conn.cookies[@remember_me_cookie]
98
+ %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
99
+
100
+ conn =
101
+ conn
102
+ |> put_req_cookie(@remember_me_cookie, signed_token)
103
+ |> UserAuth.fetch_current_user([])
104
+
105
+ assert conn.assigns.current_user.id == user.id
106
+ assert get_session(conn, :user_token) == user_token
107
+
108
+ assert get_session(conn, :live_socket_id) ==
109
+ "users_sessions:#{Base.url_encode64(user_token)}"
110
+ end
111
+
112
+ test "does not authenticate if data is missing", %{conn: conn, user: user} do
113
+ _ = Accounts.generate_user_session_token(user)
114
+ conn = UserAuth.fetch_current_user(conn, [])
115
+ refute get_session(conn, :user_token)
116
+ refute conn.assigns.current_user
117
+ end
118
+ end
119
+
120
+ describe "on_mount: mount_current_user" do
121
+ test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do
122
+ user_token = Accounts.generate_user_session_token(user)
123
+ session = conn |> put_session(:user_token, user_token) |> get_session()
124
+
125
+ {:cont, updated_socket} =
126
+ UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
127
+
128
+ assert updated_socket.assigns.current_user.id == user.id
129
+ end
130
+
131
+ test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do
132
+ user_token = "invalid_token"
133
+ session = conn |> put_session(:user_token, user_token) |> get_session()
134
+
135
+ {:cont, updated_socket} =
136
+ UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
137
+
138
+ assert updated_socket.assigns.current_user == nil
139
+ end
140
+
141
+ test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do
142
+ session = conn |> get_session()
143
+
144
+ {:cont, updated_socket} =
145
+ UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
146
+
147
+ assert updated_socket.assigns.current_user == nil
148
+ end
149
+ end
150
+
151
+ describe "on_mount: ensure_authenticated" do
152
+ test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do
153
+ user_token = Accounts.generate_user_session_token(user)
154
+ session = conn |> put_session(:user_token, user_token) |> get_session()
155
+
156
+ {:cont, updated_socket} =
157
+ UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{})
158
+
159
+ assert updated_socket.assigns.current_user.id == user.id
160
+ end
161
+
162
+ test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
163
+ user_token = "invalid_token"
164
+ session = conn |> put_session(:user_token, user_token) |> get_session()
165
+
166
+ socket = %LiveView.Socket{
167
+ endpoint: MedicalTranscriptionWeb.Endpoint,
168
+ assigns: %{__changed__: %{}, flash: %{}}
169
+ }
170
+
171
+ {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
172
+ assert updated_socket.assigns.current_user == nil
173
+ end
174
+
175
+ test "redirects to login page if there isn't a user_token", %{conn: conn} do
176
+ session = conn |> get_session()
177
+
178
+ socket = %LiveView.Socket{
179
+ endpoint: MedicalTranscriptionWeb.Endpoint,
180
+ assigns: %{__changed__: %{}, flash: %{}}
181
+ }
182
+
183
+ {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
184
+ assert updated_socket.assigns.current_user == nil
185
+ end
186
+ end
187
+
188
+ describe "on_mount: :redirect_if_user_is_authenticated" do
189
+ test "redirects if there is an authenticated user ", %{conn: conn, user: user} do
190
+ user_token = Accounts.generate_user_session_token(user)
191
+ session = conn |> put_session(:user_token, user_token) |> get_session()
192
+
193
+ assert {:halt, _updated_socket} =
194
+ UserAuth.on_mount(
195
+ :redirect_if_user_is_authenticated,
196
+ %{},
197
+ session,
198
+ %LiveView.Socket{}
199
+ )
200
+ end
201
+
202
+ test "doesn't redirect if there is no authenticated user", %{conn: conn} do
203
+ session = conn |> get_session()
204
+
205
+ assert {:cont, _updated_socket} =
206
+ UserAuth.on_mount(
207
+ :redirect_if_user_is_authenticated,
208
+ %{},
209
+ session,
210
+ %LiveView.Socket{}
211
+ )
212
+ end
213
+ end
214
+
215
+ describe "redirect_if_user_is_authenticated/2" do
216
+ test "redirects if user is authenticated", %{conn: conn, user: user} do
217
+ conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
218
+ assert conn.halted
219
+ assert redirected_to(conn) == ~p"/"
220
+ end
221
+
222
+ test "does not redirect if user is not authenticated", %{conn: conn} do
223
+ conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
224
+ refute conn.halted
225
+ refute conn.status
226
+ end
227
+ end
228
+
229
+ describe "require_authenticated_user/2" do
230
+ test "redirects if user is not authenticated", %{conn: conn} do
231
+ conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
232
+ assert conn.halted
233
+
234
+ assert redirected_to(conn) == ~p"/users/log_in"
235
+
236
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
237
+ "You must log in to access this page."
238
+ end
239
+
240
+ test "stores the path to redirect to on GET", %{conn: conn} do
241
+ halted_conn =
242
+ %{conn | path_info: ["foo"], query_string: ""}
243
+ |> fetch_flash()
244
+ |> UserAuth.require_authenticated_user([])
245
+
246
+ assert halted_conn.halted
247
+ assert get_session(halted_conn, :user_return_to) == "/foo"
248
+
249
+ halted_conn =
250
+ %{conn | path_info: ["foo"], query_string: "bar=baz"}
251
+ |> fetch_flash()
252
+ |> UserAuth.require_authenticated_user([])
253
+
254
+ assert halted_conn.halted
255
+ assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
256
+
257
+ halted_conn =
258
+ %{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
259
+ |> fetch_flash()
260
+ |> UserAuth.require_authenticated_user([])
261
+
262
+ assert halted_conn.halted
263
+ refute get_session(halted_conn, :user_return_to)
264
+ end
265
+
266
+ test "does not redirect if user is authenticated", %{conn: conn, user: user} do
267
+ conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
268
+ refute conn.halted
269
+ refute conn.status
270
+ end
271
+ end
272
+ end
test/support/conn_case.ex CHANGED
@@ -35,4 +35,30 @@ defmodule MedicalTranscriptionWeb.ConnCase do
35
  MedicalTranscription.DataCase.setup_sandbox(tags)
36
  {:ok, conn: Phoenix.ConnTest.build_conn()}
37
  end
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  end
 
35
  MedicalTranscription.DataCase.setup_sandbox(tags)
36
  {:ok, conn: Phoenix.ConnTest.build_conn()}
37
  end
38
+
39
+ @doc """
40
+ Setup helper that registers and logs in users.
41
+
42
+ setup :register_and_log_in_user
43
+
44
+ It stores an updated connection and a registered user in the
45
+ test context.
46
+ """
47
+ def register_and_log_in_user(%{conn: conn}) do
48
+ user = MedicalTranscription.AccountsFixtures.user_fixture()
49
+ %{conn: log_in_user(conn, user), user: user}
50
+ end
51
+
52
+ @doc """
53
+ Logs the given `user` into the `conn`.
54
+
55
+ It returns an updated `conn`.
56
+ """
57
+ def log_in_user(conn, user) do
58
+ token = MedicalTranscription.Accounts.generate_user_session_token(user)
59
+
60
+ conn
61
+ |> Phoenix.ConnTest.init_test_session(%{})
62
+ |> Plug.Conn.put_session(:user_token, token)
63
+ end
64
  end
test/support/fixtures/accounts_fixtures.ex ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule MedicalTranscription.AccountsFixtures do
2
+ @moduledoc """
3
+ This module defines test helpers for creating
4
+ entities via the `MedicalTranscription.Accounts` context.
5
+ """
6
+
7
+ def unique_user_email, do: "user#{System.unique_integer()}@example.com"
8
+ def valid_user_password, do: "hello world!"
9
+
10
+ def valid_user_attributes(attrs \\ %{}) do
11
+ Enum.into(attrs, %{
12
+ email: unique_user_email(),
13
+ password: valid_user_password()
14
+ })
15
+ end
16
+
17
+ def user_fixture(attrs \\ %{}) do
18
+ {:ok, user} =
19
+ attrs
20
+ |> valid_user_attributes()
21
+ |> MedicalTranscription.Accounts.register_user()
22
+
23
+ user
24
+ end
25
+
26
+ def extract_user_token(fun) do
27
+ {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
28
+ [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
29
+ token
30
+ end
31
+ end