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
Legal
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:
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:
On the prompt, click Create keys:
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.
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:
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 :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.
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:
/.elixir_ls/
Update deploy-production-environment.yml to use the new SUPABASE_ADMIN_API_KEY environment variable:
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:
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:
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
- build-ios.yml
- build-linux-arm64.yml
- build-linux-x86-64.yml
- build-macos.yml
- build-windows.yml
- lint-app.yml
- test-app.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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
<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:
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:
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:
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:
<%= 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:
<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:
<!-- Desktop sidebar -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-40 lg:flex lg:w-72 lg:flex-col">
<!-- Sticky top bar -->
<div class="
sticky
top-0
z-30
...
<!-- Mobile Bottom Navigation Bar -->
<nav class="fixed bottom-0 inset-x-0 z-40 lg:hidden">
<!-- 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:
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:
import { listenForLinkClicks } from "./link";
Add the call to listenForLinkClicks() inside the initializeLiveView function, before the liveSocket.connect() call:
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:
<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:
<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:
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:
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:
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:
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:
<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:
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:
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:
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:
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:
#![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(())
}








