8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

この記事はElixirアドベントカレンダー2025シリーズ2の19日目の記事です。

今回はユーザー登録の実装とセッション管理を実装していきます

ユーザー登録

ユーザー登録機能を実装していきます

レイアウトコンポーネント修正

最初に画面周りを修正してきます
画面全体のレイアウトコンポーネントがありますが、デフォルトのヘッダー部分が邪魔なので消します
またpy-20はモバイルだと崩れやすいのでp-4にしておきます

lib/blog_app_web/components/layouts.ex
  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_controllerguestにリクエストします

lib/blog_app_web/live/user_live/registration.ex
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を足したりして以下のようにします

lib/blog_app_web/controllers/user_session_controller.ex
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を追加します

lib/blog_app_web/router.ex
  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を作っておきます

lib/blog_app/accounts/user.ex:L18
  @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では例外を発生させます

lib/blog_app/accounts.ex:L79
  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扱いされてログインページとかに飛ばされます

動作確認

動作確認用に以下のページを作成します

lib/blog_app_web/live/user_live/welcome.ex
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
lib/blog_app_web/router.ex
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

ユーザ作成後にリダイレクトされるようにします

lib/blog_app_web/controllers/user_session_controller.ex
  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

今の実装だと新しくセッションを貼るときに都度トークンを生成する関数が実行されるのでその関数は次で実装するとして、一旦端末のを使うように変更します

lib/blog_app_web/user_auth.ex:L112
  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

8e23ebe0ac291fe9b140a863f9cdbae0.gif

最後に

APIサーバーとアプリの構成で

  • 登録画面の作成と全体レイアウトの調整
  • accounts.exの不要な部分を削除して雛形作成
  • ユーザー登録API実行とレスポンスのハンドリング
  • 作成後のログイン後画面の表示
    を実装しました

次はセッションの維持周りを実装してきます

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?