Skip to content

Settings

The settings view allows the user to manage their account and links against various other web pages, such as the app roadmap and legal pages. Additionally a link to the app internal view Backups is provided.

The settings view is available even if the user is not logged in.

Structure

The settings view is split into 4 distinct section explained in more detail below. All links on the settings view except the Backups link navigate to a different web page. The Backups link navigates to the Backups view within the app internally. For the external links, a new browser tab is opened to display the link. This is true even for the desktop and mobile apps.

The Login or Create Account, Log out and Delete Account buttons also internally navigate to the login view. The Delete Account button furthermore first opens a confirmation modal requiring the user to confirm if they really want to delete their account.

Account

The account section allows the user to login or create an account if they haven't done so already. For already logged in users, the user can delete their account or log out from the settings view.

Additionally there is a link to the backups view.

Developer

The developer section contains links to the below pages:

  • About
  • Contact

App

The app section contains links to the below pages:

  • Download app
  • Manual
  • Roadmap
  • Changelog
  • Development
  • Analytics

The legal section contains links to the below pages:

  • Cookie Consent
  • Terms of Service
  • Acceptable Use Policy
  • Privacy Policy
  • Cookie Policy
  • Third-party

Dependencies

App

To open the external links, we will need to add the dependency tauri-plugin-http. Add the below content to Cargo.toml:

Cargo.toml
tauri-plugin-http = "2.5.7"

Configuration

In the production code we will add a new endpoint to delete the user's account. The Tauri app will call this endpoint. To distinguish between the test and production environments we will add a new environment variable.

Deleting a user's account requires using a Supabase Client with the Supabase secret key. This key should not be made public, i.e. it should be kept secret. Therefore, unlike the Supabase publishable key which we have already embedded in our Tauri app, we cannot include the secret key in our Tauri app. The Supabase secret key is only added to the server-side part of our Phoenix Framework app. The Tauri app will be able to delete user accounts by calling a new endpoint in the Phoenix Framework app. We will add a new endpoint to delete user accounts.

We should start by creating a Supabase secret key. Go to your Supabase dashboard, click the Settings icon on the left sidebar, select API Keys, select the Publishable and secret API keys tab and click on Create new API keys:

Supabase Admin Key 1

On the prompt, click Create keys:

Supabase Admin Key 2

Your keys should have now been generated. Copy the secret key.

Warning

It is critically important to keep your Supabase secret key truly secret. Never embed it into client side apps, such as front-end web apps, mobile apps or desktop apps. In the case of Epic Fantasy Forge, we will use the Supabase secret key only in the server-side part of our Phoenix Framework web app. Be especially careful to not by accident use the Supabase secret key in client-side code of your Phoenix Framework web app.

Supabase Admin Key 3

Create a new GitLab CI variable for your Supabase secret key. Set the type to Variable, the Visibility to Masked and hidden and the Key to SUPABASE_ADMIN_API_KEY_PRODUCTION. Paste the secret key you copied in the above step into the Value field. Then click Add variable:

Supabase Admin Key 4

Execute the above steps both for your test and production environments.

Web

Update config.exs to include the below. The EpicFantasyForge.Supabase.Client should already be included.

config.exs
config :epic_fantasy_forge,
       :supabase_admin_client_api,
       EpicFantasyForge.Supabase.AdminClient

config :epic_fantasy_forge,
       :supabase_client_api,
       EpicFantasyForge.Supabase.Client

config :epic_fantasy_forge, :supabase_auth_api, Supabase.Auth

config :epic_fantasy_forge, :supabase_auth_admin_api, Supabase.Auth.Admin

config :epic_fantasy_forge, :elixir_process_api, Process

Update runtime.exs to include the below. Add this above the if config_env() == :prod do block.

runtime.exs
config :epic_fantasy_forge, EpicFantasyForge.Supabase.AdminClient,
  base_url:
    System.get_env("SUPABASE_URL") ||
      raise("""
      Environment variable SUPABASE_URL is missing.
      For example: https://database.epicfantasyforge.com
      """),
  api_key:
    System.get_env("SUPABASE_ADMIN_API_KEY") ||
      raise("""
      Environment variable SUPABASE_ADMIN_API_KEY is missing.
      You can find it in your Supabase project settings.
      """),
  auth: %{
    flow_type: :pkce
  }

Update .gitignore to include the below content:

.gitignore
/.elixir_ls/

Update deploy-production-environment.yml to use the new SUPABASE_ADMIN_API_KEY environment variable:

deploy-production-environment.yml
script:
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "echo \"$CI_JOB_TOKEN\" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin"
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker pull $CI_REGISTRY_IMAGE:production-environment"
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker container rm -f production-environment"
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker run -d --publish 80:80 --name production-environment --env SECRET_KEY_BASE=$SECRET_KEY_BASE_PRODUCTION --env DATABASE_URL=$DATABASE_URL_PRODUCTION --env PHX_HOST=epicfantasyforge.com --env SUPABASE_URL=$SUPABASE_URL_PRODUCTION --env SUPABASE_API_KEY=$SUPABASE_API_KEY_PRODUCTION --env SUPABASE_ADMIN_API_KEY=$SUPABASE_ADMIN_API_KEY_PRODUCTION $CI_REGISTRY_IMAGE:production-environment"

Update deploy-test-environment.yml to use the new SUPABASE_ADMIN_API_KEY environment variable:

deploy-test-environment.yml
script:
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "echo \"$CI_JOB_TOKEN\" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin"
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker pull $CI_REGISTRY_IMAGE:test-environment"
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker container rm -f test-environment"
  - ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker run -d --publish 80:80 --name test-environment --env SECRET_KEY_BASE=$SECRET_KEY_BASE_TEST --env DATABASE_URL=$DATABASE_URL_TEST --env PHX_HOST=test.epicfantasyforge.com --env SUPABASE_URL=$SUPABASE_URL_TEST --env SUPABASE_API_KEY=$SUPABASE_API_KEY_TEST --env SUPABASE_ADMIN_API_KEY=$SUPABASE_ADMIN_API_KEY_TEST $CI_REGISTRY_IMAGE:test-environment"

Update test-web.yml to use the new SUPABASE_ADMIN_API_KEY environment variable:

test-web.yml
before_script:
  - |
    if [ -n "$RELEASE" ]; then
      export SUPABASE_URL="$SUPABASE_URL_PRODUCTION"
      export SUPABASE_API_KEY="$SUPABASE_API_KEY_PRODUCTION"
      export SUPABASE_ADMIN_API_KEY="$SUPABASE_ADMIN_API_KEY_PRODUCTION"
    else
      export SUPABASE_URL="$SUPABASE_URL_TEST"
      export SUPABASE_API_KEY="$SUPABASE_API_KEY_TEST"
      export SUPABASE_ADMIN_API_KEY="$SUPABASE_ADMIN_API_KEY_TEST"
    fi

App

Update the below CI scripts to include the new environment variable EFF_BASE_URL:

build-android.yml
before_script:
  - |
    if [ -n "$RELEASE" ]; then
      export EFF_BASE_URL="https://epicfantasyforge.com"
      export SUPABASE_URL="$SUPABASE_URL_PRODUCTION"
      export SUPABASE_API_KEY="$SUPABASE_API_KEY_PRODUCTION"
    else
      export EFF_BASE_URL="https://test.epicfantasyforge.com"
      export SUPABASE_URL="$SUPABASE_URL_TEST"
      export SUPABASE_API_KEY="$SUPABASE_API_KEY_TEST"
    fi

Tests

E2E Test

We will add a manual E2E test for the settings view to Qase. Add a new test case to the Common suite named Settings.

Settings Test 1

Settings Test 2

Settings Test 3

Settings Test 4

Settings Test 5

Automated Tests

Web

In the test/epic_fantasy_forge_web/live directory of your Phoenix project, create a new file named link_test.exs and populate it with the below content:

link_test.exs
defmodule EpicFantasyForgeWeb.LinkTest do
  use ExUnit.Case, async: true

  alias EpicFantasyForgeWeb.Link
  alias Phoenix.LiveView.JS

  @external_link "https://test.epicfantasyforge.com/about"

  test "opens external link" do
    assert Link.navigate(@external_link) ==
             JS.dispatch("eff:open-external-link",
               detail: %{url: @external_link}
             )
  end

  test "opens cookie consent" do
    assert Link.navigate("cookie-consent") ==
             JS.dispatch("eff:open-cookie-consent")
  end

  test "does not do anything for unimplemented links" do
    assert Link.navigate("backups") == nil
  end
end

In the test/support/authentication directory of your Phoenix project, create a new file named app_atoms.ex and populate it with the below content:

app_atoms.ex
defmodule EpicFantasyForgeWeb.TestAppAtoms do
  alias EpicFantasyForgeWeb.TestUserAtoms

  @code %{
    "1" => "1",
    "2" => "2",
    "3" => "3",
    "4" => "4",
    "5" => "5",
    "6" => "6"
  }

  @path "/app"

  @access_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkhlbnJpayBCcvxzZWNrZSIsImlhdCI6MTc2OTYxNDY3MywiZXhwIjoxNzY5NjE1NjczfQ.fN2CVIX6KQhuYhvsmGWR3-grcWpKVv8DLBqNFZc31ag"
  @refresh_token "hq0dbissreog"

  def code, do: @code
  def path, do: @path
  def access_token, do: @access_token
  def refresh_token, do: @refresh_token

  def session,
    do: %{
      access_token: @access_token,
      refresh_token: @refresh_token,
      user: %{id: TestUserAtoms.id()}
    }
end

In the test/support/authentication directory, create a new file named user_atoms.ex and populate it with the below content:

user_atoms.ex
defmodule EpicFantasyForgeWeb.TestUserAtoms do
  @id "eevpgajn-i7ud-fdg9-xhx4-jljvvr60va5v"

  def id, do: @id
end

In the test/support directory of your Phoenix project, create a new file named app_utilities.exs and populate it with the below content:

app_utilities.exs
defmodule EpicFantasyForgeWeb.AppUtilities do
  use EpicFantasyForgeWeb.ConnCase

  import Mox
  import Phoenix.LiveViewTest

  alias EpicFantasyForgeWeb.AuthenticationUtilities
  alias EpicFantasyForgeWeb.TestAppAtoms

  def when_message_is_scheduled(
        expected_refresh_token \\ TestAppAtoms.refresh_token(),
        expected_delay_in_ms \\ 1_800_000
      ) do
    expect(
      EpicFantasyForgeWeb.ElixirProcessAPIMock,
      :send_after,
      fn _,
         {:refresh_session, ^expected_refresh_token},
         ^expected_delay_in_ms ->
        make_ref()
      end
    )
  end

  def when_message_is_cancelled do
    expect(
      EpicFantasyForgeWeb.ElixirProcessAPIMock,
      :cancel_timer,
      fn _refresh_timer ->
        0
      end
    )
  end

  def when_is_authenticated(conn) do
    AuthenticationUtilities.when_get_client_succeeds()
    AuthenticationUtilities.when_otp_login_succeeds()
    AuthenticationUtilities.when_get_client_succeeds()
    AuthenticationUtilities.when_verification_succeeds()

    {:ok, view, _html} = live(conn, TestAppAtoms.path())
    render_click(view, "login_with_otp", %{email: "user@example.com"})
    render_click(view, "verify_otp_code", %{code: TestAppAtoms.code()})

    view
  end

  def when_is_unauthenticated(conn) do
    {:ok, view, _html} = live(conn, TestAppAtoms.path())
    render_click(view, "continue_without_account")
    render_click(view, "confirm_without_account")

    view
  end
end

In the test/support/authentication directory, rename the existing file test_utilities.exs to authentication_utilities.exs and replace the file contents with the below:

authentication_utilities.exs
defmodule EpicFantasyForgeWeb.AuthenticationUtilities do
  import Mox

  alias EpicFantasyForgeWeb.AppUtilities
  alias EpicFantasyForgeWeb.TestAppAtoms
  alias EpicFantasyForgeWeb.TestOAuthAtoms

  @email "user@example.com"
  @otp_code "123456"

  @otp_credentials %{
    email: @email,
    options: %{
      should_create_user: true
    }
  }

  @otp_params %{
    email: @email,
    token: @otp_code,
    type: :magiclink
  }

  def when_get_client_fails do
    expect(
      EpicFantasyForgeWeb.SupabaseClientAPIMock,
      :get_client,
      fn -> {:error, :timeout} end
    )
  end

  def when_get_client_succeeds do
    expect(
      EpicFantasyForgeWeb.SupabaseClientAPIMock,
      :get_client,
      fn -> {:ok, %Supabase.Client{}} end
    )
  end

  def when_oauth_login_fails do
    expected_credentials = TestOAuthAtoms.oauth_credentials()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :sign_in_with_oauth,
      fn _client, ^expected_credentials ->
        {:error, :timeout}
      end
    )
  end

  def when_oauth_login_succeeds do
    expected_credentials = TestOAuthAtoms.oauth_credentials()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :sign_in_with_oauth,
      fn _client, ^expected_credentials ->
        {:ok,
         %{
           url: TestOAuthAtoms.oauth_url(),
           code_verifier: TestOAuthAtoms.code_verifier()
         }}
      end
    )
  end

  def when_otp_login_fails do
    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :sign_in_with_otp,
      fn _client, @otp_credentials ->
        {:error, :timeout}
      end
    )
  end

  def when_otp_login_succeeds do
    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :sign_in_with_otp,
      fn _client, @otp_credentials ->
        :ok
      end
    )
  end

  def when_verification_fails do
    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :verify_otp,
      fn _client, @otp_params ->
        {:error, :timeout}
      end
    )
  end

  def when_verification_succeeds do
    AppUtilities.when_message_is_scheduled()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :verify_otp,
      fn _client, @otp_params ->
        {:ok, TestAppAtoms.session()}
      end
    )
  end

  def when_verification_returns_invalid_session do
    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :verify_otp,
      fn _client, @otp_params ->
        {:ok, nil}
      end
    )
  end

  def when_code_exchange_fails do
    expected_code = TestOAuthAtoms.code()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :exchange_code_for_session,
      fn _client, ^expected_code, nil ->
        {:error, :timeout}
      end
    )
  end

  def when_code_exchange_succeeds do
    AppUtilities.when_message_is_scheduled()

    expected_code = TestOAuthAtoms.code()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :exchange_code_for_session,
      fn _client, ^expected_code, nil ->
        {:ok, TestAppAtoms.session()}
      end
    )
  end

  def when_code_exchange_returns_invalid_session do
    expected_code = TestOAuthAtoms.code()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :exchange_code_for_session,
      fn _client, ^expected_code, nil ->
        {:ok, nil}
      end
    )
  end
end

In the test/support/authentication directory, create a new file named account_admin_utilities.exs and populate it with the below content:

account_admin_utilities.exs
defmodule EpicFantasyForgeWeb.AccountAdminUtilities do
  import Mox

  alias EpicFantasyForgeWeb.TestAppAtoms
  alias EpicFantasyForgeWeb.TestUserAtoms

  def when_logout_fails do
    expected_session = TestAppAtoms.session()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAdminAPIMock,
      :sign_out,
      fn _client, ^expected_session ->
        {:error, :timeout}
      end
    )
  end

  def when_logout_succeeds do
    expected_session = TestAppAtoms.session()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAdminAPIMock,
      :sign_out,
      fn _client, ^expected_session ->
        :ok
      end
    )
  end

  def when_account_deletion_fails do
    expected_user_id = TestUserAtoms.id()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAdminAPIMock,
      :delete_user,
      fn _client, ^expected_user_id ->
        {:error, :timeout}
      end
    )
  end

  def when_account_deletion_succeeds do
    expected_user_id = TestUserAtoms.id()

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAdminAPIMock,
      :delete_user,
      fn _client, ^expected_user_id ->
        :ok
      end
    )
  end
end

In the test/support/authentication directory, create a new file named user_utilities.exs and populate it with the below content:

user_utilities.exs
defmodule EpicFantasyForgeWeb.UserUtilities do
  import Mox

  alias Elixir.Supabase.Auth.Session
  alias EpicFantasyForgeWeb.TestAppAtoms
  alias EpicFantasyForgeWeb.TestUserAtoms

  def when_get_user_fails do
    expected_session = %Session{
      access_token: TestAppAtoms.session().access_token
    }

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :get_user,
      fn _client, ^expected_session ->
        {:error, :timeout}
      end
    )
  end

  def when_get_user_succeeds do
    expected_session = %Session{
      access_token: TestAppAtoms.session().access_token
    }

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :get_user,
      fn _client, ^expected_session ->
        {:ok, %Supabase.Auth.User{id: TestUserAtoms.id()}}
      end
    )
  end
end

In the test/support/authentication directory, create a new file named refresh_session_utilities.exs and populate it with the below content:

refresh_session_utilities.exs
defmodule EpicFantasyForgeWeb.RefreshSessionUtilities do
  import Mox

  alias EpicFantasyForgeWeb.TestAppAtoms

  def when_refresh_session_fails do
    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :refresh_session,
      fn _client, _refresh_token ->
        {:error, :timeout}
      end
    )
  end

  def when_refresh_session_succeeds(response \\ []) do
    expected_refresh_token = TestAppAtoms.refresh_token()

    response =
      Keyword.get(
        response,
        :response,
        {:ok,
         %{
           access_token: TestAppAtoms.access_token(),
           refresh_token: TestAppAtoms.refresh_token()
         }}
      )

    expect(
      EpicFantasyForgeWeb.SupabaseAuthAPIMock,
      :refresh_session,
      fn _client, ^expected_refresh_token ->
        response
      end
    )
  end
end

In the test/epic_fantasy_forge_web/live/authentication directory, create a new file named login_logout_test.exs and populate it with the below content:

login_logout_test.exs
defmodule EpicFantasyForgeWeb.LoginLogoutTest do
  use EpicFantasyForgeWeb.ConnCase

  import Mox
  import Phoenix.LiveViewTest

  Code.require_file(
    "../../../support/app_utilities.exs",
    __DIR__
  )

  Code.require_file(
    "../../../support/authentication/account_admin_utilities.exs",
    __DIR__
  )

  Code.require_file(
    "../../../support/authentication/authentication_utilities.exs",
    __DIR__
  )

  alias EpicFantasyForgeWeb.AccountAdminUtilities
  alias EpicFantasyForgeWeb.AppUtilities
  alias EpicFantasyForgeWeb.AuthenticationUtilities

  setup :verify_on_exit!

  test "shows error when getting Supabase client for logout fails", %{
    conn: conn
  } do
    view = AppUtilities.when_is_authenticated(conn)
    AuthenticationUtilities.when_get_client_fails()

    render_click(view, "logout", %{})

    assert has_element?(view, "#overview")
    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#login")
    refute has_element?(view, "#loading-spinner")
    refute has_element?(view, "#toast-info")
  end

  test "shows error when logout fails", %{
    conn: conn
  } do
    view = AppUtilities.when_is_authenticated(conn)
    AuthenticationUtilities.when_get_client_succeeds()
    AccountAdminUtilities.when_logout_fails()

    render_click(view, "logout", %{})

    assert has_element?(view, "#overview")
    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#login")
    refute has_element?(view, "#loading-spinner")
    refute has_element?(view, "#toast-info")
  end

  test "shows success when logout succeeds", %{
    conn: conn
  } do
    view = AppUtilities.when_is_authenticated(conn)
    AuthenticationUtilities.when_get_client_succeeds()
    AccountAdminUtilities.when_logout_succeeds()
    AppUtilities.when_message_is_cancelled()

    render_click(view, "logout", %{})

    assert has_element?(view, "#login")
    assert has_element?(view, "#toast-info")
    refute has_element?(view, "#overview")
    refute has_element?(view, "#toast-error")
    refute has_element?(view, "#loading-spinner")
  end

  test "shows login view when unauthenticed user wants to log in", %{
    conn: conn
  } do
    view = AppUtilities.when_is_unauthenticated(conn)

    render_click(view, "login", %{})

    assert has_element?(view, "#login")
    refute has_element?(view, "#toast-info")
    refute has_element?(view, "#overview")
    refute has_element?(view, "#toast-error")
    refute has_element?(view, "#loading-spinner")
  end
end

In the test/epic_fantasy_forge_web/live/authentication directory, create a new file named delete_account_test.exs and populate it with the below content:

delete_account_test.exs
defmodule EpicFantasyForgeWeb.DeleteAccountTest do
  use EpicFantasyForgeWeb.ConnCase

  Code.require_file(
    "../../../support/authentication/user_utilities.exs",
    __DIR__
  )

  import Mox
  import Phoenix.LiveViewTest

  alias EpicFantasyForgeWeb.AccountAdminUtilities
  alias EpicFantasyForgeWeb.AppUtilities
  alias EpicFantasyForgeWeb.AuthenticationUtilities

  setup :verify_on_exit!

  test "shows warning modal when user attempts to delete account", %{
    conn: conn
  } do
    view = AppUtilities.when_is_authenticated(conn)

    render_click(view, "delete_account")

    assert has_element?(view, "#overview")
    assert has_element?(view, "#modal-warning")
    refute has_element?(view, "#login")
  end

  test "dismisses warning modal when user decides to keep account", %{
    conn: conn
  } do
    view = AppUtilities.when_is_authenticated(conn)

    render_click(view, "delete_account")
    render_click(view, "cancel_modal")

    assert has_element?(view, "#overview")
    refute has_element?(view, "#login")
    refute has_element?(view, "#modal-warning")
  end

  test "shows error when getting Supabase client for account deletion fails",
       %{conn: conn} do
    view = AppUtilities.when_is_authenticated(conn)
    AuthenticationUtilities.when_get_client_fails()

    render_click(view, "delete_account")
    render_click(view, "confirm_delete_account")

    assert has_element?(view, "#overview")
    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#login")
    refute has_element?(view, "#modal-warning")
  end

  test "shows error when account deletion fails",
       %{conn: conn} do
    view = AppUtilities.when_is_authenticated(conn)
    AuthenticationUtilities.when_get_client_succeeds()
    AccountAdminUtilities.when_account_deletion_fails()

    render_click(view, "delete_account")
    render_click(view, "confirm_delete_account")

    assert has_element?(view, "#overview")
    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#login")
    refute has_element?(view, "#modal-warning")
  end

  test "shows success when account deletion succeeds",
       %{conn: conn} do
    view = AppUtilities.when_is_authenticated(conn)
    AuthenticationUtilities.when_get_client_succeeds()
    AccountAdminUtilities.when_account_deletion_succeeds()
    AppUtilities.when_message_is_cancelled()

    render_click(view, "delete_account")
    render_click(view, "confirm_delete_account")

    assert has_element?(view, "#login")
    assert has_element?(view, "#toast-info")
    refute has_element?(view, "#overview")
    refute has_element?(view, "#modal-warning")
  end
end

In the test/epic_fantasy_forge_web/live/authentication directory, create a new file named refresh_session_test.exs and populate it with the below content:

refresh_session_test.exs
defmodule EpicFantasyForgeWeb.RefreshSessionTest do
  use EpicFantasyForgeWeb.ConnCase

  import Mox
  import Phoenix.LiveViewTest

  Code.require_file(
    "../../../support/app_utilities.exs",
    __DIR__
  )

  Code.require_file(
    "../../../support/authentication/authentication_utilities.exs",
    __DIR__
  )

  Code.require_file(
    "../../../support/authentication/refresh_session_utilities.exs",
    __DIR__
  )

  alias EpicFantasyForgeWeb.AppUtilities
  alias EpicFantasyForgeWeb.AuthenticationUtilities
  alias EpicFantasyForgeWeb.RefreshSessionUtilities
  alias EpicFantasyForgeWeb.TestAppAtoms

  setup :verify_on_exit!

  test "shows error when getting Supabase client for refresh session fails", %{
    conn: conn
  } do
    AuthenticationUtilities.when_get_client_fails()

    {:ok, view, _html} = live(conn, TestAppAtoms.path(), session: nil)
    send(view.pid, {:refresh_session, TestAppAtoms.refresh_token()})
    render(view)

    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#toast-info")
    assert_push_event(view, "show-toast", %{})
  end

  test "shows error when when session refresh fails", %{
    conn: conn
  } do
    AuthenticationUtilities.when_get_client_succeeds()
    RefreshSessionUtilities.when_refresh_session_fails()

    {:ok, view, _html} = live(conn, TestAppAtoms.path(), session: nil)
    send(view.pid, {:refresh_session, TestAppAtoms.refresh_token()})
    render(view)

    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#toast-info")
    assert_push_event(view, "show-toast", %{})
  end

  test "shows success banner when session refresh succeeds", %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    RefreshSessionUtilities.when_refresh_session_succeeds()
    AppUtilities.when_message_is_scheduled()

    {:ok, view, _html} = live(conn, TestAppAtoms.path(), session: nil)
    send(view.pid, {:refresh_session, TestAppAtoms.refresh_token()})
    render(view)

    assert has_element?(view, "#toast-info")
    refute has_element?(view, "#toast-error")
    assert_push_event(view, "show-toast", %{})
  end

  test "shows error when received refresh token is invalid", %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()

    RefreshSessionUtilities.when_refresh_session_succeeds(
      response: {:ok, %{refresh_token: nil}}
    )

    {:ok, view, _html} = live(conn, TestAppAtoms.path(), session: nil)
    send(view.pid, {:refresh_session, TestAppAtoms.refresh_token()})
    render(view)

    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#toast-info")
    assert_push_event(view, "show-toast", %{})
  end

  test "shows error when received session is invalid", %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    RefreshSessionUtilities.when_refresh_session_succeeds(response: {:ok, nil})

    {:ok, view, _html} = live(conn, TestAppAtoms.path(), session: nil)
    send(view.pid, {:refresh_session, TestAppAtoms.refresh_token()})
    render(view)

    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#toast-info")
    assert_push_event(view, "show-toast", %{})
  end

  test "schedules refresh session with default delay when token expiration time is missing",
       %{conn: _} do
    expected_refresh_time_ms = 1_800_000

    AppUtilities.when_message_is_scheduled(
      TestAppAtoms.refresh_token(),
      expected_refresh_time_ms
    )

    EpicFantasyForgeWeb.RefreshSession.schedule_session_refresh(%{
      refresh_token: TestAppAtoms.refresh_token()
    })
  end

  test "schedules refresh session in half the token expiration time", %{conn: _} do
    token_expiration_time_ms = 1_800_000
    expected_refresh_time_ms = 900_000

    AppUtilities.when_message_is_scheduled(
      TestAppAtoms.refresh_token(),
      expected_refresh_time_ms
    )

    EpicFantasyForgeWeb.RefreshSession.schedule_session_refresh(%{
      expires_in: div(token_expiration_time_ms, 1_000),
      refresh_token: TestAppAtoms.refresh_token()
    })
  end
end

In the test/epic_fantasy_forge_web/controllers directory of your Phoenix project, create a new file named account_controller_test.exs and populate it with the below content:

account_controller_test.exs
defmodule EpicFantasyForgeWeb.AccountControllerTest do
  use EpicFantasyForgeWeb.ConnCase

  import Mox

  alias EpicFantasyForgeWeb.AccountAdminUtilities
  alias EpicFantasyForgeWeb.AuthenticationUtilities
  alias EpicFantasyForgeWeb.TestAppAtoms
  alias EpicFantasyForgeWeb.UserUtilities

  setup :verify_on_exit!

  test "cannot delete account without authorization", %{conn: conn} do
    conn = delete_account(conn, isAuthenticated: false)

    assert conn.status == 401
  end

  test "cannot delete account when getting Supabase client for getting user fails",
       %{conn: conn} do
    AuthenticationUtilities.when_get_client_fails()

    conn = delete_account(conn, isAuthenticated: true)

    assert conn.status == 500
  end

  test "cannot delete account when getting user fails", %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    UserUtilities.when_get_user_fails()

    conn = delete_account(conn, isAuthenticated: true)

    assert conn.status == 500
  end

  test "cannot delete account when getting Supabase client for account deletion fails",
       %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    UserUtilities.when_get_user_succeeds()
    AuthenticationUtilities.when_get_client_fails()

    conn = delete_account(conn, isAuthenticated: true)

    assert conn.status == 500
  end

  test "cannot delete account when account deletion fails", %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    UserUtilities.when_get_user_succeeds()
    AuthenticationUtilities.when_get_client_succeeds()
    AccountAdminUtilities.when_account_deletion_fails()

    conn = delete_account(conn, isAuthenticated: true)

    assert conn.status == 500
  end

  test "can delete account", %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    UserUtilities.when_get_user_succeeds()
    AuthenticationUtilities.when_get_client_succeeds()
    AccountAdminUtilities.when_account_deletion_succeeds()

    conn = delete_account(conn, isAuthenticated: true)

    assert conn.status == 204
  end

  defp delete_account(conn, isAuthenticated: true) do
    conn
    |> put_req_header("authorization", "Bearer " <> TestAppAtoms.access_token())
    |> delete("/api/delete-account")
  end

  defp delete_account(conn, isAuthenticated: false) do
    delete(conn, "/api/delete-account")
  end
end

Replace the contents of test_helper.exs located in the test directory of your Phoenix project with the below content:

test_helper.exs
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(EpicFantasyForge.Repo, :manual)

Mox.defmock(EpicFantasyForgeWeb.ElixirProcessAPIMock,
  for: EpicFantasyForgeWeb.ElixirProcessAPI
)

Mox.defmock(EpicFantasyForgeWeb.SupabaseClientAPIMock,
  for: EpicFantasyForgeWeb.SupabaseClientAPI
)

Mox.defmock(EpicFantasyForgeWeb.SupabaseAuthAPIMock,
  for: EpicFantasyForgeWeb.SupabaseAuthAPI
)

Mox.defmock(EpicFantasyForgeWeb.SupabaseAuthAdminAPIMock,
  for: EpicFantasyForgeWeb.SupabaseAuthAdminAPI
)

Application.put_env(
  :epic_fantasy_forge,
  :elixir_process_api,
  EpicFantasyForgeWeb.ElixirProcessAPIMock
)

Application.put_env(
  :epic_fantasy_forge,
  :supabase_client_api,
  EpicFantasyForgeWeb.SupabaseClientAPIMock
)

Application.put_env(
  :epic_fantasy_forge,
  :supabase_admin_client_api,
  EpicFantasyForgeWeb.SupabaseClientAPIMock
)

Application.put_env(
  :epic_fantasy_forge,
  :supabase_auth_api,
  EpicFantasyForgeWeb.SupabaseAuthAPIMock
)

Application.put_env(
  :epic_fantasy_forge,
  :supabase_auth_admin_api,
  EpicFantasyForgeWeb.SupabaseAuthAdminAPIMock
)

App

In the src-tauri/tests directory of your Tauri project, create a new file named authentication.rs and populate it with the below content:

authentication.rs
mod utilities;

use epic_fantasy_forge_lib::dependencies::dependencies::MockDependencies;
use epic_fantasy_forge_lib::authentication::authentication::refresh_session;
use std::sync::Arc;
use utilities::{get_session, REFRESH_TOKEN};

#[test]
fn shows_error_when_refresh_token_is_invalid() {
    let mock = MockDependencies::new();
    let mut session = get_session();
    session.refresh_token = "".to_string();

    let dependencies = Arc::new(mock);
    let result = refresh_session(dependencies, session);

    assert!(result.is_err());
}

#[test]
fn shows_error_when_session_refresh_fails() {
    let mut mock = MockDependencies::new();
    mock.expect_sleep().returning(|_| ());
    mock.expect_refresh_session()
      .returning(|_| Err(supabase_auth::error::Error::InternalError));

    let dependencies = Arc::new(mock);
    let result = refresh_session(dependencies, get_session());

    assert!(result.is_err());
}

#[test]
fn shows_success_when_session_refresh_succeeds() {
    let mut mock = MockDependencies::new();
    mock.expect_sleep().returning(|_| ());
    mock.expect_refresh_session()
      .returning(|_| Ok(get_session()));
    mock.expect_save_refresh_token()
      .withf(|token| token == REFRESH_TOKEN)
      .returning(|_| Ok(()));

    let dependencies = Arc::new(mock);
    let result = refresh_session(dependencies, get_session());

    assert!(result.is_ok());
}

#[test]
fn schedules_refresh_session_with_default_delay_when_token_expiration_time_is_invalid() {
    let expected_delay_ms = 30 * 60 * 1000; // 30 minutes
    let mut session = get_session();
    session.expires_in = 0;
    let mut mock = MockDependencies::new();
    mock
      .expect_sleep()
      .withf(move |duration| { duration.as_millis() == expected_delay_ms })
      .returning(|_| ());
    mock.expect_refresh_session()
      .returning(|_| Ok(get_session()));
    mock.expect_save_refresh_token()
      .withf(|token| token == REFRESH_TOKEN)
      .returning(|_| Ok(()));

    let dependencies = Arc::new(mock);
    let result = refresh_session(dependencies, session);

    assert!(result.is_ok());
}

#[test]
fn schedules_refresh_session_in_half_the_token_expiration_time() {
    let token_expiration_time_ms = 30 * 60 * 1000; // 30 minutes
    let expected_delay_ms = 15 * 60 * 1000; // 15 minutes
    let mut session = get_session();
    session.expires_in = token_expiration_time_ms / 1000;
    let mut mock = MockDependencies::new();
    mock
      .expect_sleep()
      .withf(move |duration| { duration.as_millis() == expected_delay_ms })
      .returning(|_| ());
    mock.expect_refresh_session()
      .returning(|_| Ok(get_session()));
    mock.expect_save_refresh_token()
      .withf(|token| token == REFRESH_TOKEN)
      .returning(|_| Ok(()));

    let dependencies = Arc::new(mock);
    let result = refresh_session(dependencies, session);

    assert!(result.is_ok());
}

In the src-tauri/tests directory, create a new file named logout.rs and populate it with the below content:

logout.rs
mod utilities;

use epic_fantasy_forge_lib::dependencies::dependencies::MockDependencies;
use epic_fantasy_forge_lib::authentication::authentication::{
  SESSION,
  SESSION_COUNTER,
  logout
};
use std::sync::Arc;
use utilities::get_session;

#[test]
fn shows_error_when_logout_fails() {
  let session = get_session();
  SESSION.lock().unwrap().replace(session.clone());
  let mut mock = MockDependencies::new();
  mock.expect_logout()
    .returning(|_| Err(supabase_auth::error::Error::InternalError));
  mock.expect_emit()
    .withf(|event, payload| event == "Error" && payload == "Log out failed")
    .return_const(());

  let dependencies = Arc::new(mock);
  logout(dependencies.clone());

  assert_eq!(*SESSION.lock().unwrap(), Some(session));
  assert_eq!(*SESSION_COUNTER.lock().unwrap(), 0);
}

#[test]
fn shows_success_when_logout_succeeds() {
  let session = get_session();
  SESSION.lock().unwrap().replace(session.clone());
  let mut mock = MockDependencies::new();
  mock.expect_logout()
    .returning(|_| Ok(()));
  mock.expect_emit()
    .withf(
      |event, payload| 
      event == "Success" && 
      payload == "Logged out")
    .return_const(());
  mock.expect_emit()
    .withf(
      |event, payload| 
      event == "Transition" && 
      payload == "Login")
    .return_const(());

  let dependencies = Arc::new(mock);
  logout(dependencies.clone());

  assert_eq!(*SESSION.lock().unwrap(), None);
  assert_eq!(*SESSION_COUNTER.lock().unwrap(), 1);
}

In the src-tauri/tests directory, create a new file named delete_account.rs and populate it with the below content:

delete_account.rs
mod utilities;

use epic_fantasy_forge_lib::dependencies::dependencies::MockDependencies;
use epic_fantasy_forge_lib::authentication::authentication::{
  delete_account,
  SESSION,
  SESSION_COUNTER
};
use epic_fantasy_forge_lib::utilities::default_error;
use std::sync::Arc;
use utilities::get_session;

#[test]
fn shows_error_when_account_deletion_fails() {
  let session = get_session();
  SESSION.lock().unwrap().replace(session.clone());
  let mut mock = MockDependencies::new();
  mock.expect_delete_account()
    .returning(|_| Err(default_error()));
  mock.expect_emit()
    .withf(
      |event, payload|
      event == "Error" &&
      payload == "Account deletion failed"
    )
    .return_const(());

  let dependencies = Arc::new(mock);
  delete_account(dependencies.clone());

  assert_eq!(*SESSION.lock().unwrap(), Some(session));
  assert_eq!(*SESSION_COUNTER.lock().unwrap(), 0);
}

#[test]
fn shows_success_when_account_deletion_succeeds() {
  let session = get_session();
  SESSION.lock().unwrap().replace(session.clone());
  let mut mock = MockDependencies::new();
  mock.expect_delete_account()
    .returning(|_| Ok(()));
  mock.expect_emit()
    .withf(
      |event, payload| 
      event == "Success" && 
      payload == "Account deleted")
    .return_const(());
  mock.expect_emit()
    .withf(
      |event, payload| 
      event == "Transition" && 
      payload == "Login")
    .return_const(());

  let dependencies = Arc::new(mock);
  delete_account(dependencies.clone());

  assert_eq!(*SESSION.lock().unwrap(), None);
  assert_eq!(*SESSION_COUNTER.lock().unwrap(), 1);
}

Production Code

Web

In the lib/epic_fantasy_forge_web directory of your Phoenix project, create a new directory named utilities. Inside this new directory create a new file named supabase.ex and populate it with the below content:

supabase.ex
defmodule EpicFantasyForgeWeb.Supabase do
  def supabase_admin_client do
    Application.get_env(:epic_fantasy_forge, :supabase_admin_client_api)
  end

  def supabase_client do
    Application.get_env(:epic_fantasy_forge, :supabase_client_api)
  end

  def supabase_auth do
    Application.get_env(:epic_fantasy_forge, :supabase_auth_api)
  end

  def supabase_auth_admin do
    Application.get_env(:epic_fantasy_forge, :supabase_auth_admin_api)
  end
end

Delete the file supabase_go_true_api.ex located in the directory lib/epic_fantasy_forge_web/api in your Phoenix project. This file is replaced by the two new files that we will add below.

In the lib/epic_fantasy_forge_web/api directory, create a new file named supabase_auth_api.ex and populate it with the below content:

supabase_auth_api.ex
defmodule EpicFantasyForgeWeb.SupabaseAuthAPI do
  @moduledoc """
  Supabase Auth API interface. Allows for mocking in tests.
  """

  @callback sign_in_with_oauth(
              client :: EpicFantasyForge.Supabase.Client,
              credentials :: map()
            ) ::
              {:ok, %{url: String, code_verifier: String}} | {:error, any()}

  @callback sign_in_with_otp(
              client :: EpicFantasyForge.Supabase.Client,
              credentials :: map()
            ) ::
              :ok | {:error, any()}

  @callback verify_otp(
              client :: EpicFantasyForge.Supabase.Client,
              params :: map()
            ) ::
              {:ok, map()} | {:error, any()}

  @callback exchange_code_for_session(
              client :: EpicFantasyForge.Supabase.Client,
              code :: String,
              code_verifier :: String
            ) ::
              {:ok, map()} | {:error, any()}

  @callback refresh_session(
              client :: EpicFantasyForge.Supabase.Client,
              refresh_token :: String
            ) ::
              {:ok, map()} | {:error, any()}

  @callback get_user(
              client :: EpicFantasyForge.Supabase.Client,
              session :: EpicFantasyForge.Supabase.Session
            ) ::
              {:ok, Supabase.Auth.User.t()} | {:error, any()}

  def sign_in_with_oauth(client, credentials) do
    impl().sign_in_with_oauth(client, credentials)
  end

  def sign_in_with_otp(client, credentials) do
    impl().sign_in_with_otp(client, credentials)
  end

  def verify_otp(client, params) do
    impl().verify_otp(client, params)
  end

  def exchange_code_for_session(client, code, code_verifier) do
    impl().exchange_code_for_session(client, code, code_verifier)
  end

  def refresh_session(client, refresh_token) do
    impl().refresh_session(client, refresh_token)
  end

  def get_user(client, session) do
    impl().get_user(client, session)
  end

  defp impl,
    do:
      Application.get_env(
        :epic_fantasy_forge,
        :supabase_auth_api,
        Supabase.Auth
      )
end

In the lib/epic_fantasy_forge_web/api directory, create a new file named supabase_auth_admin_api.ex and populate it with the below content:

supabase_auth_admin_api.ex
defmodule EpicFantasyForgeWeb.SupabaseAuthAdminAPI do
  @moduledoc """
  Supabase Auth Admin API interface. Allows for mocking in tests.
  """

  @callback delete_user(
              client :: EpicFantasyForge.Supabase.Client,
              user_id :: String
            ) ::
              :ok | {:error, any()}

  @callback sign_out(
              client :: EpicFantasyForge.Supabase.Client,
              session :: map()
            ) ::
              :ok | {:error, any()}

  def delete_user(client, user_id) do
    impl().delete_user(client, user_id)
  end

  def sign_out(client, session) do
    impl().sign_out(client, session, :local)
  end

  defp impl,
    do:
      Application.get_env(
        :epic_fantasy_forge,
        :supabase_auth_admin_api,
        Supabase.Auth.Admin
      )
end

In the lib/epic_fantasy_forge_web/api directory, create a new file named elixir_process_api.ex and populate it with the below content:

elixir_process_api.ex
defmodule EpicFantasyForgeWeb.ElixirProcessAPI do
  @moduledoc """
  Elixir Process API interface. Allows for mocking in tests.
  """

  @callback send_after(pid, term, non_neg_integer) :: reference()
  @callback cancel_timer(reference()) :: non_neg_integer() | false | :ok

  def send_after(dest, msg, time), do: impl().send_after(dest, msg, time)
  def cancel_timer(timer_ref), do: impl().cancel_timer(timer_ref)

  defp impl,
    do:
      Application.get_env(
        :epic_fantasy_forge,
        :elixir_process_api,
        Process
      )
end

In the lib/epic_fantasy_forge/supabase directory, create a new file named admin_client.ex and populate it with the below content:

admin_client.ex
defmodule EpicFantasyForge.Supabase.AdminClient do
  use Supabase.Client, otp_app: :epic_fantasy_forge
end

Update application.ex to include the new EpicFantasyForge.Supabase.AdminClient in the children list:

application.ex
children = [
  EpicFantasyForgeWeb.Telemetry,
  EpicFantasyForge.Repo,
  {DNSCluster,
   query:
     Application.get_env(:epic_fantasy_forge, :dns_cluster_query) || :ignore},
  {Phoenix.PubSub, name: EpicFantasyForge.PubSub},
  {Finch, name: EpicFantasyForge.Finch},
  EpicFantasyForgeWeb.Endpoint,
  EpicFantasyForge.Supabase.Client,
  EpicFantasyForge.Supabase.AdminClient
]

Replace the contents of authentication.ex (structs) to add a new RefreshSessionDetails struct:

authentication.ex
defmodule EpicFantasyForgeWeb.AuthenticationDetails do
  defstruct client: nil, details: nil
end

defmodule EpicFantasyForgeWeb.RefreshSessionDetails do
  defstruct client: nil, refresh_token: nil
end

defmodule EpicFantasyForgeWeb.PKCE do
  defstruct code: nil, code_verifier: nil
end

In the lib/epic_fantasy_forge_web/components/app directory, create a new file named modal_atoms.ex and populate it with the below content:

modal_atoms.ex
defmodule EpicFantasyForgeWeb.ModalAtoms do
  @modal_cancel_event "cancel_modal"

  def cancel_event, do: @modal_cancel_event
end

In the lib/epic_fantasy_forge_web/live/utilities/authentication directory, create a new file named account_modal.ex and populate it with the below content:

account_modal.ex
defmodule EpicFantasyForgeWeb.AccountModal do
  alias EpicFantasyForgeWeb.ModalAtoms
  alias EpicFantasyForgeWeb.ModalButtons
  alias EpicFantasyForgeWeb.ModalDescription

  @no_account_modal_event "confirm_without_account"
  @delete_account_modal_event "confirm_delete_account"

  def no_account_modal_event, do: @no_account_modal_event
  def delete_account_modal_event, do: @delete_account_modal_event

  def get_no_account_modal do
    %EpicFantasyForgeWeb.Modal{
      description: %ModalDescription{
        title: "Risk of data loss",
        text:
          "Without an account, your world will be stored in your web browser's storage (IndexedDB). If any of the below occur, your world may be lost:",
        bullets: [
          "Clearing your web browser data",
          "Uninstalling your web browser",
          "Exceeding storage space quota"
        ]
      },
      buttons: %ModalButtons{
        positive_label: "Use account",
        negative_label: "Continue without account",
        positive_event: ModalAtoms.cancel_event(),
        negative_event: @no_account_modal_event
      }
    }
  end

  def get_delete_account_modal do
    %EpicFantasyForgeWeb.Modal{
      description: %ModalDescription{
        title: "This action cannot be undone",
        text:
          "Deleting your account will permanently and irreversibly delete your account and all associated data. This includes but is not limited to:",
        bullets: [
          "All worlds associated with your account",
          "All text associated with your account",
          "All images associated with your account"
        ]
      },
      buttons: %ModalButtons{
        positive_label: "Keep account",
        negative_label: "Delete account",
        positive_event: ModalAtoms.cancel_event(),
        negative_event: @delete_account_modal_event
      }
    }
  end
end

In the lib/epic_fantasy_forge_web/live/utilities directory, create a new file named app.ex and populate it with the below content:

app.ex
defmodule EpicFantasyForgeWeb.App do
  def process do
    Application.get_env(:epic_fantasy_forge, :elixir_process_api)
  end
end

Delete the file authentication.ex located in the directory lib/epic_fantasy_forge_web/live/utilities in your Phoenix project. This file is replaced by a new authentication.ex in the authentication subdirectory below.

In the lib/epic_fantasy_forge_web/live/utilities directory, create a new directory named authentication. Move the file named oauth.ex into this new directory. Additionally replace the file contents with the below:

oauth.ex
defmodule EpicFantasyForgeWeb.OAuth do
  @moduledoc false

  import Phoenix.LiveView
  import Phoenix.Component

  require Logger

  alias EpicFantasyForgeWeb.Supabase

  @error_message "Login failed"
  @is_dev_environment Mix.env() == :dev
  @path "/app"

  def get_redirect_url do
    host =
      Application.get_env(:epic_fantasy_forge, EpicFantasyForgeWeb.Endpoint)[
        :url
      ][:host]

    port =
      Application.get_env(:epic_fantasy_forge, EpicFantasyForgeWeb.Endpoint)[
        :http
      ][:port]

    # In development, we use the test environment (test.epicfantasyforge.com)
    # for authentication. In Supabase it doesn't seem possible to configure a
    # localhost redirect URL. Therefore we must use the test environment
    # redirect URL and update the /etc/hosts file on our development machine to
    # resolve the domain name test.epicfantasyforge.com to 127.0.0.1 whenever
    # we are testing locally.
    if @is_dev_environment do
      "http://test.epicfantasyforge.com:#{port}#{@path}"
    else
      "https://#{host}#{@path}"
    end
  end

  def login_with_oauth(socket, credentials) do
    case Supabase.supabase_client().get_client() do
      {:ok, client} ->
        authentication_details = %EpicFantasyForgeWeb.AuthenticationDetails{
          client: client,
          details: credentials
        }

        supabase_oauth_login(socket, authentication_details)

      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @error_message)

        Logger.critical("Failed to get Supabase client")
        {:noreply, socket}
    end
  end

  defp supabase_oauth_login(socket, authentication_details) do
    case Supabase.supabase_auth().sign_in_with_oauth(
           authentication_details.client,
           authentication_details.details
         ) do
      {:ok, %{url: oauth_url, code_verifier: code_verifier}} ->
        {:noreply,
         socket
         |> Phoenix.LiveView.push_event("o-auth-redirect", %{
           url: oauth_url,
           code_verifier: code_verifier
         })}

      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @error_message)

        Logger.error("Failed to initiate OAuth login")
        {:noreply, socket}
    end
  end
end

In the lib/epic_fantasy_forge_web/live/utilities directory, move the file named otp.ex into the new authentication directory. Additionally replace the file contents with the below:

otp.ex
defmodule EpicFantasyForgeWeb.OTP do
  @moduledoc false

  import Phoenix.LiveView
  import Phoenix.Component

  require Logger

  alias EpicFantasyForgeWeb.RefreshSession
  alias EpicFantasyForgeWeb.Supabase

  @error_msg "Login failed"
  @sending_success_msg "Code sent"
  @sending_error_msg "Code sending failed"
  @verification_success_msg "Logged in"
  @verification_error_msg "Code verification failed"

  def login_with_otp(socket, credentials) do
    case Supabase.supabase_client().get_client() do
      {:ok, client} ->
        authentication_details = %EpicFantasyForgeWeb.AuthenticationDetails{
          client: client,
          details: credentials
        }

        supabase_otp_login(socket, authentication_details)

      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @sending_error_msg)

        Logger.critical("Failed to get Supabase client")
        {:noreply, socket}
    end
  end

  def verify_otp_code(socket, params) do
    case Supabase.supabase_client().get_client() do
      {:ok, client} ->
        authentication_details = %EpicFantasyForgeWeb.AuthenticationDetails{
          client: client,
          details: params
        }

        supabase_verify_otp_code(socket, authentication_details)

      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @verification_error_msg)

        Logger.critical("Failed to get Supabase client")
        {:noreply, socket}
    end
  end

  defp supabase_otp_login(socket, authentication_details) do
    case Supabase.supabase_auth().sign_in_with_otp(
           authentication_details.client,
           authentication_details.details
         ) do
      :ok ->
        socket =
          socket
          |> assign(loading: nil, is_verifying: true)
          |> put_flash(:info, @sending_success_msg)
          |> push_event("show-toast", %{})

        {:noreply, socket}

      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @sending_error_msg)
          |> push_event("show-toast", %{})

        Logger.error("Failed to initiate OTP login")
        {:noreply, socket}
    end
  end

  defp supabase_verify_otp_code(socket, authentication_details) do
    case Supabase.supabase_auth().verify_otp(
           authentication_details.client,
           authentication_details.details
         ) do
      {:ok, session} ->
        schedule_session_refresh(socket, session)

      {:error, _reason} ->
        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, @verification_error_msg)
          |> push_event("show-toast", %{})

        Logger.error("Failed to verify OTP code")
        {:noreply, socket}
    end
  end

  defp schedule_session_refresh(socket, session) do
    case RefreshSession.schedule_session_refresh(session) do
      :error ->
        socket =
          socket
          |> assign(loading: nil, is_verifying: false)
          |> put_flash(:error, @error_msg)
          |> push_event("show-toast", %{})

        Logger.error("Failed to schedule session refresh")
        {:noreply, socket}

      refresh_timer ->
        navigate_to_overview(socket, session, refresh_timer)
    end
  end

  defp navigate_to_overview(socket, session, refresh_timer) do
    socket =
      socket
      |> assign(
        loading: nil,
        is_verifying: false,
        refresh_timer: refresh_timer,
        session: session,
        view: "overview"
      )
      |> put_flash(:info, @verification_success_msg)
      |> push_event("show-toast", %{})
      |> push_event("show-animation", %{})

    {:noreply, socket}
  end
end

In the lib/epic_fantasy_forge_web/live/utilities/authentication directory, create a new file named authentication.ex and populate it with the below content:

authentication.ex
defmodule EpicFantasyForgeWeb.Authentication do
  import Phoenix.LiveView
  import Phoenix.Component

  alias EpicFantasyForgeWeb.RefreshSession
  alias EpicFantasyForgeWeb.Supabase

  require Logger

  @error_message "Login failed"
  @success_message "Logged in"

  @path "/app"

  @dialyzer {:no_match, exchange_code_for_session: 2}
  def exchange_code_for_session(socket, pkce) do
    case Supabase.supabase_client().get_client() do
      {:ok, client} ->
        case Supabase.supabase_auth().exchange_code_for_session(
               client,
               pkce.code,
               pkce.code_verifier
             ) do
          {:ok, session} ->
            schedule_session_refresh(socket, session)

          {:error, _reason} ->
            Logger.error("Failed to exchange code for session")
            redirect_on_error(socket)
        end

      {:error, _reason} ->
        Logger.critical("Failed to get Supabase client")
        redirect_on_error(socket)
    end
  end

  def logout(socket) do
    session = socket.assigns.session

    with {:ok, client} <- Supabase.supabase_client().get_client(),
         :ok <- Supabase.supabase_auth_admin().sign_out(client, session) do
      socket =
        socket
        |> RefreshSession.cancel()
        |> assign(
          loading: nil,
          session: nil,
          view: "login"
        )
        |> put_flash(:info, "Logged out")
        |> push_event("show-toast", %{})

      {:noreply, socket}
    else
      {:error, _reason} ->
        Logger.error("Failed to log out")

        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, "Log out failed")
          |> push_event("show-toast", %{})

        {:noreply, socket}
    end
  end

  def delete_account(socket, session) do
    case EpicFantasyForgeWeb.AccountController.delete_account(session) do
      :ok ->
        socket =
          socket
          |> RefreshSession.cancel()
          |> assign(
            loading: nil,
            session: nil,
            view: "login"
          )
          |> put_flash(:info, "Account deleted")
          |> push_event("show-toast", %{})

        {:noreply, socket}

      {:error, _reason} ->
        Logger.error("Failed to delete account")

        socket =
          socket
          |> assign(loading: nil)
          |> put_flash(:error, "Account deletion failed")
          |> push_event("show-toast", %{})

        {:noreply, socket}
    end
  end

  defp redirect_on_error(socket) do
    {:noreply,
     socket
     |> assign(session: nil)
     |> put_flash(:error, @error_message)
     |> push_patch(to: @path)}
  end

  defp schedule_session_refresh(socket, session) do
    case RefreshSession.schedule_session_refresh(session) do
      :error ->
        Logger.error("Failed to schedule session refresh")
        redirect_on_error(socket)

      refresh_timer ->
        {:noreply,
         socket
         |> assign(
           refresh_timer: refresh_timer,
           session: session,
           view: "overview"
         )
         |> put_flash(:info, @success_message)
         |> push_event("show-toast", %{})
         |> push_event("show-animation", %{})
         |> push_patch(to: @path)}
    end
  end
end

In the lib/epic_fantasy_forge_web/live/utilities/authentication directory, create a new file named refresh_session.ex and populate it with the below content:

refresh_session.ex
defmodule EpicFantasyForgeWeb.RefreshSession do
  import Phoenix.LiveView
  import Phoenix.Component

  alias EpicFantasyForgeWeb.App
  alias EpicFantasyForgeWeb.Supabase

  require Logger

  @refresh_error_message "Authentication session renewal failed"
  @refresh_success_message "Authentication session renewed"

  def refresh_session(socket, refresh_token) do
    case Supabase.supabase_client().get_client() do
      {:ok, client} ->
        refresh_session_details = %EpicFantasyForgeWeb.RefreshSessionDetails{
          client: client,
          refresh_token: refresh_token
        }

        supabase_refresh_session(socket, refresh_session_details)

      {:error, _reason} ->
        socket =
          socket
          |> put_flash(:error, @refresh_error_message)
          |> push_event("show-toast", %{})

        Logger.critical("Failed to get Supabase client")
        {:noreply, socket}
    end
  end

  def schedule_session_refresh(session) when is_map(session) do
    expires_in = Map.get(session, :expires_in)
    refresh_token = Map.get(session, :refresh_token)

    delay_in_ms =
      case expires_in do
        expires_in_seconds
        when is_integer(expires_in_seconds) and expires_in_seconds > 0 ->
          expires_in_ms = expires_in_seconds * 1000
          round(expires_in_ms / 2)

        # 30 minutes
        _ ->
          30 * 60 * 1000
      end

    case refresh_token do
      nil ->
        Logger.error("Invalid refresh token")
        :error

      _ ->
        App.process().send_after(
          self(),
          {:refresh_session, refresh_token},
          delay_in_ms
        )
    end
  end

  def schedule_session_refresh(_session) do
    Logger.error("Invalid session for session refresh scheduling")
    :error
  end

  def cancel(socket) do
    case socket.assigns[:refresh_timer] do
      nil ->
        socket

      refresh_timer ->
        App.process().cancel_timer(refresh_timer)

        # Consume the pending refresh message if there is one. This prevents a
        # race condition where the refresh timer triggered before it was
        # cancelled. The refresh message would be in the mailbox in this
        # scenario. By consuming the message here we prevent it from being
        # processed.
        receive do
          {:refresh_session, _} -> :ok
        after
          0 -> :ok
        end

        assign(socket, refresh_timer: nil)
    end
  end

  defp supabase_refresh_session(socket, refresh_session_details) do
    case Supabase.supabase_auth().refresh_session(
           refresh_session_details.client,
           refresh_session_details.refresh_token
         ) do
      {:ok, session} ->
        case schedule_session_refresh(session) do
          :error ->
            socket =
              socket
              |> put_flash(:error, @refresh_error_message)
              |> push_event("show-toast", %{})

            Logger.error("Failed to schedule session refresh")
            {:noreply, socket}

          refresh_timer ->
            {:noreply,
             socket
             |> assign(session: session, refresh_timer: refresh_timer)
             |> put_flash(:info, @refresh_success_message)
             |> push_event("show-toast", %{})}
        end

      {:error, _reason} ->
        socket =
          socket
          |> put_flash(:error, @refresh_error_message)
          |> push_event("show-toast", %{})

        Logger.error("Failed to refresh session")
        {:noreply, socket}
    end
  end
end

In the lib/epic_fantasy_forge_web/components/app directory, create a new file named link.ex and populate it with the below content:

link.ex
defmodule EpicFantasyForgeWeb.Link do
  alias Phoenix.LiveView.JS

  def navigate(target) do
    cond do
      String.starts_with?(target, "https://") ->
        JS.dispatch("eff:open-external-link", detail: %{url: target})

      target == "cookie-consent" ->
        JS.dispatch("eff:open-cookie-consent")

      true ->
        nil
    end
  end
end

In the lib/epic_fantasy_forge_web/components/app/templates directory, create a new file named link.html.heex and populate it with the below content:

link.html.heex
<button
  type="button"
  class="
    group
    w-full
    rounded-xl
    border
    border-white/10
    bg-gray-900/30
    backdrop-blur
    p-3
    shadow-sm
    transition
    hover:scale-105
    xl:hover:scale-[1.025] 
    2xl:hover:scale-[1.015]
    focus:outline-none
    focus:ring-2
    focus:ring-indigo-500
    focus:ring-offset-2
    focus:ring-offset-gray-900/0
    flex
    items-center
    cursor-pointer"
  phx-click={EpicFantasyForgeWeb.Link.navigate(@target)}
>
  <div class="flex items-center gap-3">
    <span
      aria-hidden="true"
      class="
        flex
        size-9
        shrink-0
        items-center
        justify-center
        text-2xl
        leading-none
        text-white
        emoji
        rounded-lg
        bg-indigo-500/10
        ring-1
        ring-indigo-400/20"
    >
      {@icon}
    </span>
    <div class="text-left">
      <h3 class="text-sm font-medium text-white">{@label}</h3>
      <%= if is_binary(assigns[:description]) and
        String.trim(assigns[:description]) != "" do %>
        <p class="text-xs text-gray-300">{assigns[:description]}</p>
      <% end %>
    </div>
  </div>

  <%= if String.starts_with?(@target, ["http://", "https://"]) do %>
    <svg
      viewBox="0 0 122.88 121.93"
      class="ml-auto h-5 w-5 shrink-0 text-indigo-500"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      focusable="false"
    >
      <g>
        <path
          fill="currentColor"
          d="M8.33,0.02h29.41v20.6H20.36v80.7h82.1V84.79h20.36v37.14H0V0.02H8.33L8.33,0.02z M122.88,0H53.3l23.74,23.18l-33.51,33.5 l21.22,21.22L98.26,44.4l24.62,24.11V0L122.88,0z"
        />
      </g>
    </svg>
  <% end %>
</button>

In the lib/epic_fantasy_forge_web/controllers directory, create a new directory named api. Move the file session_controller.ex from lib/epic_fantasy_forge_web/controllers inside this new directory.

In the lib/epic_fantasy_forge_web/controllers/api directory, create a new file named account_controller.ex and populate it with the below content:

account_controller.ex
defmodule EpicFantasyForgeWeb.AccountController do
  use EpicFantasyForgeWeb, :controller

  require Logger

  alias Elixir.Supabase.Auth.Session
  alias EpicFantasyForgeWeb.Supabase

  def delete(conn, _params) do
    with ["Bearer " <> access_token] <- get_req_header(conn, "authorization"),
         {:ok, client} <- Supabase.supabase_client().get_client(),
         {:ok, user} <-
           Supabase.supabase_auth().get_user(client, %Session{
             access_token: access_token
           }),
         :ok <- delete_account(%{user: user}) do
      send_resp(conn, 204, "")
    else
      [] ->
        Logger.error("Account deletion failed: HTTP 401")
        send_resp(conn, 401, "")

      _ ->
        Logger.error("Account deletion failed: HTTP 500")
        send_resp(conn, 500, "")
    end
  end

  def delete_account(session) do
    with %{} <- session,
         user when is_map(user) <- Map.get(session, :user),
         user_id when is_binary(user_id) <- Map.get(user, :id),
         {:ok, client} <- Supabase.supabase_admin_client().get_client(),
         :ok <-
           Supabase.supabase_auth_admin().delete_user(client, user_id) do
      :ok
    else
      _ -> {:error, :failed}
    end
  end
end

Update router.ex to add the new delete account endpoint in the /api scope:

router.ex
scope "/api", EpicFantasyForgeWeb do
  pipe_through :api

  delete "/delete-account", AccountController, :delete
  post "/session", SessionController, :set
end

Replace the contents of app_live.ex with the below content:

app_live.ex
defmodule EpicFantasyForgeWeb.AppLive do
  use Phoenix.LiveView, layout: {EpicFantasyForgeWeb.Layouts, :app}
  require Logger

  alias EpicFantasyForgeWeb.AccountModal
  alias EpicFantasyForgeWeb.Authentication
  alias EpicFantasyForgeWeb.ModalAtoms
  alias EpicFantasyForgeWeb.OAuth
  alias EpicFantasyForgeWeb.OTP
  alias EpicFantasyForgeWeb.PKCE
  alias EpicFantasyForgeWeb.RefreshSession

  @allowed_providers ~w(gitlab github google apple azure discord)
  @error_message "Login failed"
  @path "/app"

  @modal_cancel_event ModalAtoms.cancel_event()
  @modal_no_account_event AccountModal.no_account_modal_event()
  @modal_delete_account_event AccountModal.delete_account_modal_event()

  @impl true
  def mount(_params, session, socket) do
    {:ok,
     assign(socket,
       email: nil,
       is_verifying: false,
       loading: nil,
       modal: AccountModal.get_no_account_modal(),
       otp_code: %{},
       refresh_timer: nil,
       session: nil,
       session_data: session,
       should_show_modal: false,
       view: "login"
     )}
  end

  @impl true
  def handle_event("client_error", %{"error" => error}, socket) do
    socket =
      socket
      |> assign(loading: nil)
      |> put_flash(:error, error)

    {:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
  end

  @impl true
  def handle_event("login_with_oauth", %{"provider" => provider}, socket)
      when provider not in @allowed_providers do
    socket =
      socket
      |> assign(loading: nil)
      |> put_flash(:error, @error_message)

    {:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
  end

  @impl true
  def handle_event("login_with_oauth", %{"provider" => provider}, socket) do
    socket =
      socket
      |> assign(loading: "oauth_#{provider}")
      |> Phoenix.LiveView.clear_flash()

    credentials = %{
      provider: provider,
      options: %{
        redirect_to: OAuth.get_redirect_url()
      }
    }

    send(self(), {:login_with_oauth, credentials})
    {:noreply, socket}
  end

  @impl true
  def handle_event("login_with_otp", %{"email" => email}, socket) do
    socket =
      socket
      |> assign(loading: "otp", email: email)
      |> Phoenix.LiveView.clear_flash()

    credentials = %{
      email: email,
      options: %{
        should_create_user: true
      }
    }

    send(self(), {:login_with_otp, credentials})
    {:noreply, socket}
  end

  @impl true
  def handle_event("verify_otp_code", %{"code" => code_map}, socket) do
    code = code_map |> Enum.sort() |> Enum.map_join("", fn {_k, v} -> v end)

    socket =
      socket
      |> assign(loading: "otp")
      |> assign(otp_code: code_map)
      |> Phoenix.LiveView.clear_flash()

    params = %{
      email: socket.assigns.email,
      token: code,
      type: :magiclink
    }

    send(self(), {:verify_otp_code, params})
    {:noreply, socket}
  end

  @impl true
  def handle_event(@modal_cancel_event, _value, socket) do
    socket =
      socket
      |> assign(should_show_modal: false)

    {:noreply, socket}
  end

  @impl true
  def handle_event(@modal_no_account_event, _value, socket) do
    socket =
      socket
      |> assign(
        should_show_modal: false,
        view: "overview"
      )
      |> Phoenix.LiveView.clear_flash()
      |> Phoenix.LiveView.push_event("show-animation", %{})

    {:noreply, socket}
  end

  @impl true
  def handle_event("continue_without_account", _value, socket) do
    socket =
      socket
      |> assign(
        modal: AccountModal.get_no_account_modal(),
        should_show_modal: true
      )
      |> Phoenix.LiveView.clear_flash()

    {:noreply, socket}
  end

  @impl true
  def handle_event(@modal_delete_account_event, _value, socket) do
    socket =
      socket
      |> assign(
        loading: "delete_account",
        should_show_modal: false
      )
      |> Phoenix.LiveView.clear_flash()

    send(self(), {:delete_account, socket.assigns.session})
    {:noreply, socket}
  end

  @impl true
  def handle_event("delete_account", _value, socket) do
    socket =
      socket
      |> assign(
        modal: AccountModal.get_delete_account_modal(),
        should_show_modal: true
      )
      |> Phoenix.LiveView.clear_flash()

    {:noreply, socket}
  end

  @impl true
  def handle_event("login", _value, socket) do
    socket =
      socket
      |> assign(view: "login")
      |> Phoenix.LiveView.clear_flash()

    {:noreply, socket}
  end

  @impl true
  def handle_event("logout", _value, socket) do
    socket =
      socket
      |> assign(loading: "logout")
      |> Phoenix.LiveView.clear_flash()

    send(self(), {:logout})
    {:noreply, socket}
  end

  @impl true
  def handle_info({:login_with_oauth, credentials}, socket) do
    OAuth.login_with_oauth(socket, credentials)
  end

  @impl true
  def handle_info({:login_with_otp, credentials}, socket) do
    OTP.login_with_otp(socket, credentials)
  end

  @impl true
  def handle_info({:refresh_session, refresh_token}, socket) do
    RefreshSession.refresh_session(socket, refresh_token)
  end

  @impl true
  def handle_info({:verify_otp_code, params}, socket) do
    OTP.verify_otp_code(socket, params)
  end

  @impl true
  def handle_info({:logout}, socket) do
    Authentication.logout(socket)
  end

  @impl true
  def handle_info({:delete_account, session}, socket) do
    Authentication.delete_account(socket, session)
  end

  @impl true
  def handle_params(params, _uri, socket) do
    if connected?(socket) do
      cond do
        code = params["code"] ->
          session_data = socket.assigns[:session_data]
          code_verifier = session_data["code_verifier"]

          pkce = %PKCE{
            code: code,
            code_verifier: code_verifier
          }

          Authentication.exchange_code_for_session(socket, pkce)

        params["error"] ->
          socket =
            socket
            |> assign(session: nil)
            |> put_flash(:error, @error_message)
            |> push_patch(to: @path)

          Logger.error("Error in URL query parameter")
          {:noreply, socket}

        true ->
          {:noreply, socket}
      end
    else
      {:noreply, socket}
    end
  end
end

Update app_live.html.heex to pass the loading and session assigns to the overview view:

app_live.html.heex
<%= if @view == "overview" do %>
  <EpicFantasyForgeWeb.Views.overview loading={@loading} session={@session} />
<% end %>

In the lib/epic_fantasy_forge_web/views/templates directory of your Phoenix project, create a new file named settings.html.heex and populate it with the below content:

settings.html.heex
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  <!-- Account -->
  <div class="rounded-2xl border border-white/10 bg-gray-900/50 p-4">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">Account</h3>
    </div>
    <div class="mb-4">
      <EpicFantasyForgeWeb.AppComponents.link
        icon="📦"
        label="Backups"
        description="Manage your backups"
        target="backups"
      />
    </div>
    <%= if @session == nil do %>
      <button
        phx-click="login"
        type="button"
        class="
          relative
          flex
          w-full
          items-center
          justify-center
          rounded-md
          px-4
          py-1.5
          mb-4
          text-sm/6
          font-semibold
          text-white
          bg-indigo-500
          hover:bg-indigo-400
          hover:scale-105
          xl:hover:scale-[1.025]
          2xl:hover:scale-[1.015]
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
        "
        disabled={@loading != nil}
      >
        <span class="text-sm/6 font-semibold">Login or Create Account</span>
      </button>
    <% else %>
      <button
        phx-click="logout"
        type="button"
        class="
          relative
          flex
          w-full
          items-center
          justify-center
          rounded-md
          px-4
          py-1.5
          mb-4
          text-sm/6
          font-semibold
          text-white
          bg-indigo-500
          hover:bg-indigo-400
          hover:scale-105
          xl:hover:scale-[1.025]
          2xl:hover:scale-[1.015]
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
        "
        disabled={@loading != nil}
      >
        <span class="text-sm/6 font-semibold">Logout</span>
        <span class="absolute right-4" style="width: 24px; height: 24px;">
          <%= if @loading == "logout" do %>
            <EpicFantasyForgeWeb.AppComponents.loading_spinner color="white" />
          <% end %>
        </span>
      </button>
      <button
        phx-click="delete_account"
        type="button"
        class="
          relative
          flex
          w-full
          justify-center
          rounded-md
          px-4
          py-1.5
          mb-2
          text-sm/6
          font-semibold
          text-white
          bg-red-500
          hover:bg-red-400
          hover:scale-105
          xl:hover:scale-[1.025]
          2xl:hover:scale-[1.015]
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
        "
        disabled={@loading != nil}
      >
        <span class="text-sm/6 font-semibold">Delete Account</span>
        <span class="absolute right-4" style="width: 24px; height: 24px;">
          <%= if @loading == "delete_account" do %>
            <EpicFantasyForgeWeb.AppComponents.loading_spinner color="white" />
          <% end %>
        </span>
      </button>
    <% end %>
  </div>

  <!-- Developer -->
  <div class="
      rounded-2xl
      border
      border-white/10
      bg-gray-900/50
      p-4
      ">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">Developer</h3>
    </div>
    <div class="flex flex-col gap-3">
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🧑‍💻"
        label="About"
        description="Henrik Brüsecke"
        target="https://epicfantasyforge.com/about"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="📮"
        label="Contact"
        description="Email, Signal"
        target="https://epicfantasyforge.com/contact"
      />
    </div>
  </div>

  <!-- App -->
  <div class="
      rounded-2xl
      border
      border-white/10
      bg-gray-900/50
      p-4
      ">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">App</h3>
    </div>
    <div class="flex flex-col gap-3">
      <EpicFantasyForgeWeb.AppComponents.link
        icon="📦"
        label="Download"
        description="Windows, macOS, Linux, Android and iOS"
        target="https://epicfantasyforge.com/download"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="📕"
        label="Manual"
        description="User documentation"
        target="https://documentation.epicfantasyforge.com"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🛣"
        label="Roadmap"
        description="Planned features"
        target="https://feedback.epicfantasyforge.com/roadmap"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="📝"
        label="Changelog"
        description="Release history"
        target="https://epicfantasyforge.featurebase.app/changelog"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🛠️"
        label="Development"
        description="Technical documentation"
        target="https://development.epicfantasyforge.com"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="📊"
        label="Analytics"
        description="Usage statistics"
        target="https://epicfantasyforge.com"
      />
    </div>
  </div>

  <!-- Legal -->
  <div class="
      rounded-2xl
      border
      border-white/10
      bg-gray-900/50
      p-4
      ">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">Legal</h3>
    </div>
    <div class="flex flex-col gap-3">
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🍪"
        label="Cookie Consent"
        description="Adjust your cookie preferences"
        target="cookie-consent"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🤝"
        label="Terms of Service"
        target="https://epicfantasyforge.com/terms-of-service"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🚔"
        label="Acceptable Use Policy"
        target="https://epicfantasyforge.com/acceptable-use-policy"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🕵"
        label="Privacy Policy"
        target="https://epicfantasyforge.com/privacy-policy"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🍪"
        label="Cookie Policy"
        target="https://epicfantasyforge.com/cookie-policy"
      />
      <EpicFantasyForgeWeb.AppComponents.link
        icon="🧩"
        label="Third-party"
        target="https://epicfantasyforge.com/third-party"
      />
    </div>
  </div>
</div>

Update overview.html.heex to adjust z-index values and layout classes, and render the settings view in the content area. Change the desktop sidebar z-50 to z-40, the sticky top bar z-40 to z-30, and the mobile bottom navigation bar z-50 to z-40. Update the content area wrapper classes and add the settings component:

overview.html.heex
<!-- Desktop sidebar -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-40 lg:flex lg:w-72 lg:flex-col">
overview.html.heex
<!-- Sticky top bar -->
<div class="
    sticky
    top-0
    z-30
    ...
overview.html.heex
<!-- Mobile Bottom Navigation Bar -->
<nav class="fixed bottom-0 inset-x-0 z-40 lg:hidden">
overview.html.heex
<!-- Content -->
<div class="flex flex-col overflow-hidden ml-4 mb-[120px] lg:ml-0 lg:mb-4">
  <div class="
      flex-1
      min-h-0
      w-full
      rounded-2xl
      shadow-sm
      py-0
      pl-0
      pr-4
      flex
      flex-col
      overflow-hidden">
    <div class="
        flex-1
        min-h-0
        overflow-y-auto
        no-scrollbar">
      <EpicFantasyForgeWeb.Views.settings
        loading={@loading}
        session={@session}
      />
    </div>
  </div>
</div>

In the assets/ts directory of your Phoenix project, create a new file named link.ts and populate it with the below content:

link.ts
export function listenForLinkClicks() {
  window.addEventListener("eff:open-external-link", (event: Event) => {
    const url = (event as CustomEvent).detail?.url;

    if (typeof url === "string") {
      window.open(url, "_blank", "noopener, noreferrer");
    }
  });

  window.addEventListener("eff:open-cookie-consent", () => {
    document.getElementById("cookie-consent-trigger")?.click();
  });
}

Update live-view.ts to import and call listenForLinkClicks:

live-view.ts
import { listenForLinkClicks } from "./link";

Add the call to listenForLinkClicks() inside the initializeLiveView function, before the liveSocket.connect() call:

live-view.ts
listenForLinkClicks();

liveSocket.connect();

App

In the directory src/views of your Tauri project, add a new file named settings.svelte and populate it with the below content:

settings.svelte
<script lang="ts">
  import Link from '../components/link.svelte';
  import { is_authenticated, loading, Loading } from '../components/state';
  import LoadingSpinner from '../components/loading-spinner.svelte';
  import { login, logout } from '../ts/authentication';
  import { showDeleteAccountModal } from '../components/modal';
</script>

<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  <!-- Account -->
  <div class="rounded-2xl border border-white/10 bg-gray-900/50 p-4">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">Account</h3>
    </div>
    <div class="mb-4">
      <Link
        icon="📦"
        label="Backups"
        description="Manage your backups"
        target="backups"
      />
    </div>
    {#if $is_authenticated}
      <button
        type="button"
        class="
          relative
          flex
          w-full
          justify-center
          rounded-md
          px-4
          py-1.5
          mb-4
          text-sm/6
          font-semibold
          text-white
          bg-indigo-500
          hover:bg-indigo-400
          hover:scale-105
          xl:hover:scale-[1.025]
          2xl:hover:scale-[1.015]
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
          cursor-pointer"
        on:click={() => logout()}
        disabled={$loading != Loading.None}
      >
        <span class="text-sm/6 font-semibold">Log out</span>
        <span class="absolute right-4" style="width: 24px; height: 24px;">
          {#if $loading === Loading.Logout}
            <LoadingSpinner />
          {/if}
        </span>
      </button>
      <button
        type="button"
        class="
          relative
          flex
          w-full
          justify-center
          rounded-md
          px-4
          py-1.5
          mb-2
          text-sm/6
          font-semibold
          text-white
          bg-red-500
          hover:bg-red-400
          hover:scale-105
          xl:hover:scale-[1.025]
          2xl:hover:scale-[1.015]
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
          cursor-pointer"
        on:click={showDeleteAccountModal}
        disabled={$loading != Loading.None}
      >
        <span class="text-sm/6 font-semibold">Delete Account</span>
        <span class="absolute right-4" style="width: 24px; height: 24px;">
          {#if $loading === Loading.DeleteAccount}
            <LoadingSpinner />
          {/if}
        </span>
      </button>
    {:else}
      <button
        type="button"
        class="
          relative
          flex
          w-full
          justify-center
          rounded-md
          px-4
          py-1.5
          mb-4
          text-sm/6
          font-semibold
          text-white
          bg-indigo-500
          hover:bg-indigo-400
          hover:scale-105
          xl:hover:scale-[1.025]
          2xl:hover:scale-[1.015]
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
          cursor-pointer"
        on:click={() => login()}
        disabled={$loading != Loading.None}
      >
        <span class="text-sm/6 font-semibold">Log in</span>
      </button>
    {/if}
  </div>

  <!-- Developer -->
  <div class="
      rounded-2xl
      border
      border-white/10
      bg-gray-900/50
      p-4
      ">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">Developer</h3>
    </div>
    <div class="flex flex-col gap-3">
      <Link
        icon="🧑‍💻"
        label="About"
        description="Henrik Brüsecke"
        target="https://epicfantasyforge.com/about"
      />
      <Link
        icon="📮"
        label="Contact"
        description="Email, Signal"
        target="https://epicfantasyforge.com/contact"
      />
    </div>
  </div>

  <!-- App -->
  <div class="
      rounded-2xl
      border
      border-white/10
      bg-gray-900/50
      p-4
      ">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">App</h3>
    </div>
    <div class="flex flex-col gap-3">
      <Link
        icon="📦"
        label="Download"
        description="Windows, macOS, Linux, Android and iOS"
        target="https://epicfantasyforge.com/download"
      />
      <Link
        icon="📕"
        label="Manual"
        description="User documentation"
        target="https://documentation.epicfantasyforge.com"
      />
      <Link
        icon="🛣"
        label="Roadmap"
        description="Planned features"
        target="https://feedback.epicfantasyforge.com/roadmap"
      />
      <Link
        icon="📝"
        label="Changelog"
        description="Release history"
        target="https://epicfantasyforge.featurebase.app/changelog"
      />
      <Link
        icon="🛠️"
        label="Development"
        description="Technical documentation"
        target="https://development.epicfantasyforge.com"
      />
      <Link
        icon="📊"
        label="Analytics"
        description="Usage statistics"
        target="https://epicfantasyforge.com"
      />
    </div>
  </div>

  <!-- Legal -->
  <div class="
      rounded-2xl
      border
      border-white/10
      bg-gray-900/50
      p-4
      ">
    <div class="flex items-center justify-center pb-4">
      <h3 class="text-md font-semibold text-white">Legal</h3>
    </div>
    <div class="flex flex-col gap-3">
      <Link
        icon="🍪"
        label="Cookie Consent"
        description="Adjust your cookie preferences"
        target="cookie-consent"
      />
      <Link
        icon="🤝"
        label="Terms of Service"
        target="https://epicfantasyforge.com/terms-of-service"
      />
      <Link
        icon="🚔"
        label="Acceptable Use Policy"
        target="https://epicfantasyforge.com/acceptable-use-policy"
      />
      <Link
        icon="🕵"
        label="Privacy Policy"
        target="https://epicfantasyforge.com/privacy-policy"
      />
      <Link
        icon="🍪"
        label="Cookie Policy"
        target="https://epicfantasyforge.com/cookie-policy"
      />
      <Link
        icon="🧩"
        label="Third-party"
        target="https://epicfantasyforge.com/third-party"
      />
    </div>
  </div>
</div>

In the directory src/components of your Tauri project, add a new file named link.svelte and populate it with the below content:

link.svelte
<script lang="ts">
  import { openUrl } from '@tauri-apps/plugin-opener';

  export let icon: string;
  export let label: string;
  export let description: string | null = null;
  export let target: string | null = null;

  $: hasDescription = 
    typeof description === 'string' && description.trim() !== '';
  $: isExternal =
    typeof target === 'string' &&
    (target.startsWith('http://') || target.startsWith('https://'));

  function navigate() {
    if (isExternal && target) {
      openUrl(target);
    } else if (target === 'cookie-consent') {
      document.getElementById('cookie-consent-trigger')?.click();
    }
  }
</script>

<button
  type="button"
  class="
    group
    w-full
    rounded-xl
    border
    border-white/10
    bg-gray-900/30
    backdrop-blur
    p-3
    shadow-sm
    transition
    hover:scale-105
    xl:hover:scale-[1.025] 
    2xl:hover:scale-[1.015]
    focus:outline-none
    focus:ring-2
    focus:ring-indigo-500
    focus:ring-offset-2
    focus:ring-offset-gray-900/0
    flex
    items-center
    cursor-pointer"
  on:click={navigate}
>
  <div class="flex items-center gap-3">
    <span
      aria-hidden="true"
      class="
        flex
        size-9
        shrink-0
        items-center
        justify-center
        text-2xl
        leading-none
        text-white
        emoji
        rounded-lg
        bg-indigo-500/10
        ring-1
        ring-indigo-400/20"
    >
      {icon}
    </span>
    <div class="text-left">
      <h3 class="text-sm font-medium text-white">{label}</h3>
      {#if hasDescription}
        <p class="text-xs text-gray-300">{description}</p>
      {/if}
    </div>
  </div>

  {#if isExternal}
    <svg
      viewBox="0 0 122.88 121.93"
      class="ml-auto h-5 w-5 shrink-0 text-indigo-500"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      focusable="false"
    >
      <g>
        <path
          fill="currentColor"
          d="M8.33,0.02h29.41v20.6H20.36v80.7h82.1V84.79h20.36v37.14H0V0.02H8.33L8.33,0.02z M122.88,0H53.3l23.74,23.18l-33.51,33.5 l21.22,21.22L98.26,44.4l24.62,24.11V0L122.88,0z"
        />
      </g>
    </svg>
  {/if}
</button>

Replace the content of modal.ts with the below content:

modal.ts
import { deleteAccount } from "../ts/authentication";
import { initializeAnimations } from "../ts/animations";
import { tick } from "svelte";
import { view, View } from "./state";
import { writable } from "svelte/store";

export const modal = writable<Modal | null>(null);

export interface ModalDescription {
  title: string;
  text: string;
  bullets: string[];
}

export interface ModalButtons {
  positive_label: string;
  negative_label: string;
  positive_event: () => void;
  negative_event: () => void;
}

export interface Modal {
  description: ModalDescription;
  buttons: ModalButtons;
}

export function showNoAccountModal() {
  modal.set({
    description: {
      title: "Risk of data loss",
      text:
        "Without an account, your world will be stored on your device. If any of the below occur, your world may be lost:",
      bullets: [
        "Uninstalling the app",
        "Erasing your device",
        "Losing your device"
      ]
    },
    buttons: getNoAccountButtons()
  });
}

export function showDeleteAccountModal() {
  modal.set({
    description: {
      title: "This action cannot be undone",
      text:
        "Deleting your account will permanently and irreversibly delete your account and all associated data. This includes but is not limited to:",
      bullets: [
        "All worlds associated with your account",
        "All text associated with your account",
        "All images associated with your account"
      ]
    },
    buttons: getDeleteAccountButtons()
  });
}

function getNoAccountButtons(): ModalButtons {
  return {
    positive_label: "Use account",
    negative_label: "Continue without account",
    positive_event: () => {
      modal.set(null);
    },
    negative_event: async () => {
      modal.set(null);
      view.set(View.Overview);

      await tick();
      initializeAnimations();
    }
  }
}

function getDeleteAccountButtons(): ModalButtons {
  return {
    positive_label: "Keep account",
    negative_label: "Delete account",
    positive_event: () => {
      modal.set(null);
    },
    negative_event: async () => {
      modal.set(null);
      deleteAccount();
    }
  }
}

Update state.ts to include loading states for account deletion and logging out. Additionally add a new variable to track whether the user is currently logged in or not:

modal.ts
import { writable } from 'svelte/store';

export enum Loading {
  Apple = 'Apple',
  Azure = 'Azure',
  Discord = 'Discord',
  GitHub = 'GitHub',
  GitLab = 'GitLab',
  Google = 'Google',
  DeleteAccount = 'DeleteAccount',
  Logout = 'Logout',
  None = 'None',
  OTP = 'OTP'
}

export enum View {
  Login = 'Login',
  Overview = 'Overview'
}

export const is_authenticated = writable<boolean>(false);
export const loading = writable<Loading>(Loading.None);
export const view = writable<View>(View.Login);

Update authentication.ts to have functions for login, logout and account deletion:

authentication.ts
import { loading, Loading, view, View } from '../components/state';

export function login() {
  view.set(View.Login);
}

export function logout() {
  loading.set(Loading.Logout);
  invoke('tauri_logout');
}

export function deleteAccount() {
  loading.set(Loading.DeleteAccount);
  invoke('tauri_delete_account');
}

In event.ts, update the function listenForSuccessEvents to handle login and logout events:

event.ts
import {
  is_authenticated,
  Loading,
  loading,
  view,
  View
} from "../components/state";

export function listenForSuccessEvents() {
  listen(Event.Success, async (event: { payload: string }) => {
    toastError.set(null);
    toastInfo.set(event.payload || "Success");
    loading.set(Loading.None);

    switch (event.payload) {
      case "Code sent":
        is_verifying.set(true);
        break;
      case "Logged in":
        is_verifying.set(false);
        is_authenticated.set(true);
        break;
      case "Logged out":
        is_authenticated.set(false);
        break;
    }

    await tick();
    initializeToast();
  });
}

In overview.svelte, import the new Settings Svelte view:

overview.svelte
<script lang="ts">
  import Settings from './settings.svelte';
</script>

In the src-tauri/src/dependencies directory of your Tauri project, create a new file named vault.rs and populate it with the below content. This extracts the vault-specific code (previously in production_dependencies.rs) into its own module:

vault.rs
use anyhow::Result;
use blake3;
use rand::Rng;
use std::fs;
use std::time::Duration;
use tauri::Manager;
use tauri_plugin_stronghold::stronghold::Stronghold;

// This is not completely secure but a good tradeoff between security and
// usability. The encryption key is generated by combining a hardcoded part
// with a randomly generated salt. This at least guarantees that each user
// has a different key. The refresh token is not stored in plaintext.
//
// A more secure approach would use the OS keychain or prompt the user for
// a password which is not convenient.
pub fn save_refresh_token(
    app: &tauri::AppHandle,
    refresh_token: &str
  ) -> Result<()> {
  let salt = intialize_salt(app)?;
  let key = generate_key(&salt);

  let stronghold = Stronghold::new(get_vault_path(app)?, key)?;

  const CLIENT_NAME: &str = "epic_fantasy_forge";
  let client = match stronghold.inner().get_client(CLIENT_NAME) {
    Ok(client) => client,
    Err(_) => {
      stronghold.inner().create_client(CLIENT_NAME)?;
      stronghold.inner().get_client(CLIENT_NAME)?
    }
  };

  client.store().insert(
    b"refresh_token".to_vec(),
    refresh_token.as_bytes().to_vec(),
    None::<Duration>
  )?;

  stronghold.save()?;

  Ok(())
}

fn generate_key(salt: &[u8]) -> Vec<u8> {
  let mut key = Vec::new();
  key.extend(b"Gf1JLZMHV9DqqjuSeqjCsntQfKynm5us");
  key.extend_from_slice(salt);

  let hash = blake3::hash(&key);
  hash.as_bytes().to_vec()
}

fn get_vault_path(app: &tauri::AppHandle) -> Result<String> {
  Ok(app
    .path()
    .app_local_data_dir()?
    .join("epic_fantasy_forge.vault")
    .to_str()
    .ok_or(anyhow::anyhow!(""))?
    .to_string())
}

fn intialize_salt(app: &tauri::AppHandle) -> Result<Vec<u8>> {
  let salt_file = app
    .path()
    .app_local_data_dir()?
    .join("salt.txt");

  if !salt_file.exists() {
    let salt: [u8; 32] = rand::rng().random();
    fs::write(&salt_file, salt)?;
    Ok(salt.to_vec())
  } else {
    Ok(fs::read(&salt_file)?)
  }
}

In the src-tauri/src/authentication directory, create a new file named authentication.rs and populate it with the below content:

authentication.rs
use crate::dependencies::production_dependencies::{
  ArcDependencies,
  ProductionDependencies
};
use crate::utilities::default_error;

use anyhow::Result;
use std::sync::Mutex;
use std::time::Duration;
use supabase_auth::models::Session;

use std::sync::Arc;

const REFRESH_ERROR_MESSAGE : &str = "Authentication session renewal failed";
const REFRESH_SUCCESS_MESSAGE : &str = "Authentication session renewed";

pub static SESSION: Mutex<Option<Session>> = Mutex::new(None);
pub static SESSION_COUNTER: Mutex<u64> = Mutex::new(0);

#[tauri::command]
pub async fn tauri_logout(app: tauri::AppHandle) {
  std::thread::spawn(move || {
    let dependencies = ProductionDependencies::create(app.clone());
    logout(dependencies.clone());
  });
}

#[tauri::command]
pub async fn tauri_delete_account(app: tauri::AppHandle) {
  std::thread::spawn(move || {
    let dependencies = ProductionDependencies::create(app.clone());
    delete_account(dependencies.clone());
  });
}

pub fn logout(dependencies: ArcDependencies) {
  let access_token = SESSION
      .lock()
      .unwrap()
      .as_ref()
      .map(|session| session.access_token.clone())
      .unwrap_or_default();

  match dependencies.logout(access_token) {
    Ok(_) => {
      *SESSION_COUNTER.lock().unwrap() += 1;
      SESSION.lock().unwrap().take();
      dependencies.emit("Success", "Logged out");
      dependencies.emit("Transition", "Login");
    },
    Err(_error) => {
      dependencies.emit("Error", "Log out failed");
    }
  }
}

pub fn delete_account(dependencies: ArcDependencies) {
  let access_token = SESSION
      .lock()
      .unwrap()
      .as_ref()
      .map(|session| session.access_token.clone())
      .unwrap_or_default();

  match dependencies.delete_account(access_token) {
    Ok(_) => {
      *SESSION_COUNTER.lock().unwrap() += 1;
      SESSION.lock().unwrap().take();
      dependencies.emit("Success", "Account deleted");
      dependencies.emit("Transition", "Login");
    },
    Err(_error) => {
      dependencies.emit("Error", "Account deletion failed");
    }
  }
}

pub fn schedule_session_refresh(
  dependencies: ArcDependencies,
  session: Session
) {
  std::thread::spawn(move || {
    let mut current_session = session.clone();
    let initial_session_count = SESSION_COUNTER.lock().unwrap().clone();

    loop {
      match refresh_session(dependencies.clone(), current_session.clone()) {
        Ok(new_session) => {
          let counter = SESSION_COUNTER.lock().unwrap();
          if *counter != initial_session_count {
            break;
          }
          current_session = new_session.clone();
          SESSION.lock().unwrap().replace(new_session);
          drop(counter);
          dependencies.emit("Success", REFRESH_SUCCESS_MESSAGE);
        },
        Err(_) => {
          let counter = SESSION_COUNTER.lock().unwrap();
          if *counter != initial_session_count {
            break;
          }
          SESSION.lock().unwrap().take();
          drop(counter);
          dependencies.emit("Error", REFRESH_ERROR_MESSAGE);
          dependencies.emit("Transition", "Login");
          break;
        }
      };
    }
  });
}

pub fn refresh_session(
  dependencies: ArcDependencies,
  session: Session
) -> Result<Session> {
  let refresh_token = session.refresh_token;
  if refresh_token.is_empty() { 
    return Err(default_error())
  }

  let dependencies = Arc::clone(&dependencies);
  let sleep_time = compute_sleep_time(session.expires_in);

  dependencies.sleep(sleep_time);

  perform_refresh_session(dependencies.clone(), &refresh_token)
}

fn compute_sleep_time(expires_in: i64) -> Duration {
  let delay_ms = if expires_in > 0 {
    expires_in / 2 * 1000
  } else {
    30 * 60 * 1000 // 30 minutes
  };

  Duration::from_millis(delay_ms as u64)
}

fn perform_refresh_session(
  dependencies: ArcDependencies,
  refresh_token: &str
) -> Result<Session> {
  let session: Session = dependencies.refresh_session(refresh_token)?;
  dependencies.save_refresh_token(&session.refresh_token)?;

  Ok(session)
}

Update dependencies.rs to add the new trait methods for session refresh, logout, account deletion, event emission and sleep:

dependencies.rs
use anyhow::Result;
use supabase_auth::models::{
  LoginWithOAuthOptions,
  OAuthResponse,
  OTPResponse,
  Provider,
  Session
};

#[mockall::automock]
pub trait Dependencies {
  fn exchange_code_for_session(
    &self,
    auth_code: &str,
    code_verifier: &str,
  ) -> Result<Session, supabase_auth::error::Error>;

  fn login_with_oauth(
    &self,
    provider: Provider,
    options: Option<LoginWithOAuthOptions>,
  ) -> Result<OAuthResponse, supabase_auth::error::Error>;

  fn login_with_otp(
    &self,
    email: &str,
  ) -> Result<OTPResponse, supabase_auth::error::Error>;

  fn refresh_session(
    &self,
    refresh_token: &str
  ) -> Result<Session, supabase_auth::error::Error>;

  fn schedule_session_refresh(&self, session: Session);

  fn logout(
    &self,
    access_token: String
  ) -> Result<(), supabase_auth::error::Error>;

  fn verify_otp_code(
    &self,
    email: String,
    code: String,
  ) -> Result<Session, supabase_auth::error::Error>;

  fn open_url(&self, url: String) -> Result<(), tauri_plugin_opener::Error>;

  fn save_refresh_token(&self, refresh_token: &str) -> Result<()>;

  fn delete_account(
    &self,
    access_token: String
  ) -> Result<()>;

  fn emit(&self, event: &str, payload: &str);

  fn sleep(&self, duration: std::time::Duration);
}

Replace the contents of production_dependencies.rs with the below content. This refactors the module to use Arc for thread-safe sharing of dependencies, extracts vault code to vault.rs, and adds implementations for logout, delete account, session refresh, event emission and sleep:

production_dependencies.rs
use crate::authentication::authentication;
use crate::dependencies::dependencies::Dependencies;
use crate::dependencies::vault;
use crate::utilities::default_error;

use anyhow::Result;
use std::sync::Arc;
use supabase_auth::models::{
  AuthClient,
  LoginWithOAuthOptions,
  LogoutScope,
  OAuthResponse,
  OTPResponse,
  OtpType,
  Provider,
  Session,
  VerifyEmailOtpParams,
  VerifyOtpParams
};
use tauri::Emitter;
use tauri_plugin_opener::OpenerExt;

pub type ArcDependencies = Arc<dyn Dependencies + Send + Sync>;

pub struct ProductionDependencies {
  pub app: tauri::AppHandle
}

const EFF_BASE_URL: &str = env!("EFF_BASE_URL");

impl ProductionDependencies {
  pub fn create(app: tauri::AppHandle) -> ArcDependencies {
    Arc::new(Self { app })
  }

  fn supabase_client() -> AuthClient {
    AuthClient::new(
      env!("SUPABASE_URL"),
      env!("SUPABASE_API_KEY"),
      ""
    )
  }
}

impl Dependencies for ProductionDependencies {
  fn exchange_code_for_session(
    &self,
    auth_code: &str,
    code_verifier: &str,
  ) -> Result<Session, supabase_auth::error::Error> {
    tauri::async_runtime::block_on(
      Self::supabase_client().exchange_code_for_session(auth_code, code_verifier)
    )
  }

  fn login_with_oauth(
    &self, provider: Provider,
    options: Option<LoginWithOAuthOptions>
  ) -> Result<OAuthResponse, supabase_auth::error::Error> {
    Self::supabase_client().login_with_oauth(provider, options)
  }

  fn login_with_otp(
    &self,
    email: &str,
  ) -> Result<OTPResponse, supabase_auth::error::Error> {
    tauri::async_runtime::block_on(
      Self::supabase_client().send_email_with_otp(email, None)
    )
  }

  fn refresh_session(
    &self,
    refresh_token: &str
  ) -> Result<Session, supabase_auth::error::Error> {
    tauri::async_runtime::block_on(
      Self::supabase_client().refresh_session(refresh_token)
    )
  }

  fn schedule_session_refresh(&self, session: Session) {
    let dependencies = Self::create(self.app.clone());
    authentication::schedule_session_refresh(dependencies, session);
  }

  fn logout(
    &self,
    access_token: String
  ) -> Result<(), supabase_auth::error::Error> {
    tauri::async_runtime::block_on(
      Self::supabase_client().logout(Some(LogoutScope::Local), &access_token)
    )
  }

  fn open_url(&self, url: String) -> Result<(), tauri_plugin_opener::Error> {
    self.app.opener().open_url(url, None::<&str>)
  }

  fn save_refresh_token(&self, refresh_token: &str) -> Result<()> {
    vault::save_refresh_token(&self.app, refresh_token)
  }

  fn verify_otp_code(&self, email: String, code: String) ->
    Result<Session, supabase_auth::error::Error> {
    let params = VerifyEmailOtpParams {
        email: email,
        token: code,
        otp_type: OtpType::Magiclink,
        options: None
    };

    tauri::async_runtime::block_on(
      Self::supabase_client().verify_otp(VerifyOtpParams::Email(params))
    )
  }

  fn delete_account(
    &self,
    access_token: String
  ) -> Result<()> {
    let url = format!("{}/api/delete-account", EFF_BASE_URL);
    let response = tauri::async_runtime::block_on(
      tauri_plugin_http::reqwest::Client::new()
        .delete(&url)
        .header("authorization", format!("Bearer {}", access_token))
        .send()
    ).map_err(|_| default_error())?;

    if response.status().is_success() {
      Ok(())
    } else {
      Err(default_error())
    }
  }

  fn emit(&self, event: &str, payload: &str) {
    _ = self.app.emit(event, payload);
  }

  fn sleep(&self,duration:std::time::Duration) {
    std::thread::sleep(duration);
  }
}

Replace the contents of lib.rs with the below content. This registers the new authentication and vault modules, and adds the tauri_logout and tauri_delete_account commands alongside the tauri_plugin_http plugin:

lib.rs
#![allow(clippy::module_inception)]
#![allow(clippy::redundant_field_names)]
#![allow(clippy::result_large_err)]

pub mod authentication {
  pub mod authentication;
  pub mod deep_link;
  pub mod oauth;
  pub mod otp;
}

pub mod dependencies {
  pub mod dependencies;
  pub mod production_dependencies;
  pub mod vault;
}

pub mod utilities;

use authentication::authentication::tauri_delete_account;
use authentication::deep_link::tauri_on_open_url;
use authentication::oauth::tauri_login_with_oauth;
use authentication::otp::tauri_login_with_otp;
use authentication::authentication::tauri_logout;
use authentication::otp::tauri_verify_otp_code;
use tauri::Manager;
use tauri_plugin_deep_link::DeepLinkExt;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
  let mut builder = tauri::Builder::default();
  #[cfg(desktop)]
  {
    builder = builder.plugin(tauri_plugin_single_instance::init(
      |app, _args, _cwd| {
        let _ = app
          .get_webview_window("main")
          .expect("no main window")
          .set_focus();
      }
    ));
  }

  builder
    .plugin(tauri_plugin_deep_link::init())
    .plugin(tauri_plugin_http::init())
    .plugin(tauri_plugin_opener::init())
    .invoke_handler(tauri::generate_handler![
        tauri_delete_account,
        tauri_login_with_oauth,
        tauri_login_with_otp,
        tauri_logout,
        tauri_verify_otp_code
      ])
    .setup(setup)
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
  #[cfg(any(windows, target_os = "linux"))]
  {
    if let Err(e) = app.deep_link().register_all() {
      eprintln!("Failed to register deep links: {:?}", e);
    }
  }

  let app_handle = app.handle();
  let on_open_url_app_handle = app_handle.clone();
  app_handle.deep_link().on_open_url(move |event| tauri_on_open_url(
    on_open_url_app_handle.clone(), event));

  Ok(())
}