はじめに
この記事はElixirアドベントカレンダー2025シリーズ2の19日目の記事です。
今回はユーザー登録の実装とセッション管理を実装していきます
ユーザー登録
ユーザー登録機能を実装していきます
レイアウトコンポーネント修正
最初に画面周りを修正してきます
画面全体のレイアウトコンポーネントがありますが、デフォルトのヘッダー部分が邪魔なので消します
またpy-20はモバイルだと崩れやすいのでp-4にしておきます
def app(assigns) do
~H"""
- <header class="navbar px-4 sm:px-6 lg:px-8">
- <div class="flex-1">
- ...
- </div>
- </header>
- <main class="px-4 py-20 sm:px-6 lg:px-8">
+ <main class="p-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
登録画面作成
登録画面を以下のようにします
登録自体はメールアドレスを使用しないので良い感じの文言とGetStartedの登録ボタンだけ置いときます
ボタンを押した際にuser_session_controllerのguestにリクエストします
defmodule BlogAppWeb.UserLive.Registration do
use BlogAppWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="hero bg-base-200 h-[80vh]">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-4xl font-bold">A New Way to Blog</h1>
<p class="py-6">
Simple. Powerful. Built for today’s web.
</p>
<.link href={~p"/users/guest"} method="post" class="btn btn-xl btn-primary">
Get Started
</.link>
</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, %{assigns: %{current_scope: %{user: user}}} = socket)
when not is_nil(user) do
{:ok, redirect(socket, to: BlogAppWeb.UserAuth.signed_in_path(socket))}
end
def mount(_params, _session, socket) do
{:ok, socket}
end
end
session_controllerを使わない箇所を消したりguestを足したりして以下のようにします
defmodule BlogAppWeb.UserSessionController do
use BlogAppWeb, :controller
alias BlogApp.Accounts
alias BlogAppWeb.UserAuth
def guest(conn, %{}) do
case Accounts.register_user(%{id: Ecto.ULID.generate(), email: "guest"}) do
{:ok, user} ->
conn
|> put_session(:user_return_to, ~p"/users/settings")
|> UserAuth.log_in_user(user)
{:error, _} ->
conn
|> put_flash(:error, "サーバーに接続できません")
|> redirect(to: ~p"/users/register")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end
ログイントークンつきのURLは使わないので消して、先程追加したpost guestを追加します
scope "/", BlogAppWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{BlogAppWeb.UserAuth, :mount_current_scope}] do
live "/users/register", UserLive.Registration, :new
live "/users/log-in", UserLive.Login, :new
- live "/users/log-in/:token", UserLive.Confirmation, :new
end
+ post "/users/guest", UserSessionController, :guest
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
APIリクエスト実行
accounts.exを使う関数のガワだけ残してそれ以外は全部消します
defmodule BlogApp.Accounts do
@moduledoc """
The Accounts context.
"""
alias BlogApp.Api
alias BlogApp.Accounts.User
def register_user(attrs) do
end
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `BlogApp.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
def update_user_email(user, token) do
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
end
end
register_userを以下のように書き換えます
成功したらレスポンスとしてIDと認証トークンが返ってくるので、トークンはファイルに書き込み
{:ok, %User{}}を返します
失敗したらエラーレスポンスをモデルに合わせてアサインします
def register_user(attrs) do
{:ok, resp} =
Api.client(:no_auth)
|> Req.post(url: "/register", json: %{user: attrs})
case resp.status do
200 ->
File.write!(BlogApp.token_path(), resp.body["token"])
{:ok, %User{id: attrs.id}}
422 ->
%User{}
|> User.guest_changeset(attrs)
|> Api.assign_error(resp)
|> then(&{:error, &1})
_ ->
{:error, User.guest_changeset(%User{}, attrs)}
end
end
changesetをassignが必要なので以下のようにgeust_changesetを作っておきます
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
+ def guest_changeset(user, attrs) do
+ user
+ |> cast(attrs, [])
+ end
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
認証トークンからログイン情報を取得
user/statusに認証トークン付きでリクエストする関数を以下のように作成します
200の場合はtoken_insertedをDateTime型に変換
{User, token_inserted}の形で返す
401,それ以外はnilを返して400では例外を発生させます
def get_user_by_session_token(_) do
{:ok, resp} = Req.get(Api.client(), url: "/users/status")
case resp.status do
200 ->
{:ok, datetime, _} = DateTime.from_iso8601(resp.body["token_inserted_at"])
{%User{id: resp.body["id"], email: resp.body["email"]}, datetime}
401 ->
nil
400 ->
raise Ecto.NoResultsError
_ ->
nil
end
end
なぜこの形するかというと、ログインが必要な画面では以下の関数が実行されます
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
{user, _} =
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end || {nil, nil}
Scope.for_user(user)
end)
end
ちょっとわかりにくいですが以下のように分岐します
- user_token なし
if nil ||でfalsyにはいり{nil, nil} - user_token あり & ユーザーあり nilではないのでtruthyにはいり
{user, _datatime} - user_token あり & ユーザーなし nilなのでfalsyにはいり
{nil, nil}
そして{nil,nil}が返ってくるとunauthorized扱いされてログインページとかに飛ばされます
動作確認
動作確認用に以下のページを作成します
defmodule BlogAppWeb.UserLive.Welcome do
use BlogAppWeb, :live_view
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="hero bg-base-200 h-[80vh]">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-4xl font-bold">Welcome A New Blog</h1>
<p class="py-6">
your ID is {@current_scope.user.id}
</p>
</div>
</div>
</div>
</Layouts.app>
"""
end
end
scope "/", BlogAppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{BlogAppWeb.UserAuth, :require_authenticated}] do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
+ live "/welcome", UserLive.Welcome
end
post "/users/update-password", UserSessionController, :update_password
end
ユーザ作成後にリダイレクトされるようにします
def guest(conn, %{}) do
case Accounts.register_user(%{id: Ecto.ULID.generate()}) do
{:ok, user} ->
conn
- |> put_session(:user_return_to, ~p"/users/settings")
+ |> put_session(:user_return_to, ~p"/welcome")
|> UserAuth.log_in_user(user)
{:error, _} ->
conn
|> put_flash(:error, "サーバーに接続できません")
|> redirect(to: ~p"/users/register")
end
end
今の実装だと新しくセッションを貼るときに都度トークンを生成する関数が実行されるのでその関数は次で実装するとして、一旦端末のを使うように変更します
defp create_or_extend_session(conn, user, params) do
- token = Accounts.generate_user_session_token(user)
+ # token = Accounts.generate_user_session_token(user)
+ token = File.read!(BlogApp.token_path()) |> Base.url_decode64!()
remember_me = get_session(conn, :user_remember_me)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
最後に
APIサーバーとアプリの構成で
- 登録画面の作成と全体レイアウトの調整
- accounts.exの不要な部分を削除して雛形作成
- ユーザー登録API実行とレスポンスのハンドリング
- 作成後のログイン後画面の表示
を実装しました
次はセッションの維持周りを実装してきます
