Skip to content

User Account

A user account allows users of Epic Fantasy Forge to store their worlds in the cloud, enabling them to more easily share worlds between devices and with other users. User data and their worlds are stored both in the cloud and on the user's devices.

It is also possible to use Epic Fantasy Forge without an account. In this case the user's data and worlds are only stored on the user's devices and not in the cloud. This may be the preferred option for data-privacy conscious users. It is also useful to give users the option to use the app without an account for demo and testing purposes.

Login Methods

A modern app should provide convenient and secure ways to login. Epic Fantasy Forge uses Supabase to handle authentication. If you have followed the User Authentication and Database sections of this guide then you should already have a Supabase account.

Supabase offers multiple login methods. The login methods chosen for Epic Fantasy Forge are:

  • OAuth
    • GitLab
    • GitHub
    • Google
    • Apple
    • Microsoft
    • Discord
  • OTP
    • Email

OAuth (Open Authorization) allows users to log into your app using their existing accounts with other third-party providers. This is a very convenient way to login. The OAuth providers available on Epic Fantasy Forge were chosen as follows:

  • GitLab & GitHub for developers
  • Google & Apple for mobile users
  • Microsoft & Discord for desktop users

The above providers should cover most users.

OTP (One Time Password) allows users to log into your app using a login code sent to their contact details. This is a modern login option where users don't have to come up with a password for their account. The user account will be tied to the user's contact details, however it is possible to update the contact details and use the new contact details to login.

The OTP option is only available for email in Epic Fantasy Forge. It is also possible to have OTP with phone numbers, however this was deemed too unsecure as phone numbers are recycled when you cancel your phone contract. This means that in future another user may get your old phone number. With emails this risk is reduced since emails are generally not recycled.

OAuth

When users login using OAuth, they will be redirected to the OAuth provider (e.g. GitLab) where they will need to login and confirm if they wish to share some information with Epic Fantasy Forge. Upon successful or unsuccesful login to the third-party provider, they will be redirected back to Epic Fantasy Forge. Technically Supabase sits in the middle of this process as an intermediary.

PKCE

For additional security we will use OAuth with PKCE (Proof Key for Code Exchange). Without PKCE, the session and token are returned as a URL query parameter in the redirect back to Epic Fantasy Forge on successful login. Whilst we are using HTTPS, it is not ideal to pass a secret as a URL query parameter since it will be recorded in the web browser's history. Additionally the URL query parameter may be leaked to analytics platforms such as Pirsch Analytics, the analytics platform used by Epic Fantasy Forge.

For more details about PKCE, please see the Authorization Code Flow with Proof Key for Code Exchange (PKCE) page by auth0.

Callback URL

To enable OAuth for our application with OAuth providers (e.g. GitLab), we need to provide a redirect URL. As Supabase is an intermediary, we will configure a Supabase callback URL on the OAuth provider side (e.g. GitLab). Later, when implementing the actual code, we will separately pass another redirect URL with our OAuth request to Supabase. The redirect URL passed to Supabase will redirect back to Epic Fantasy Forge. In summary the flow goes like this:

Client -> Supabase -> OAuth provider (e.g. GitLab)

Client <- Supabase <- OAuth provider (e.g. GitLab)

To find the the callback URL, start by logging into your Supabase project. Select "Test" for your test environment or "Production" for your production environment. We will need both the test environment and production callback URLs as we will configure separate applications on the OAuth provider side for the test environment and production environment.

Deep Linking

With OAuth, the user is redirected to the third-party authentication provider. After login, they are redirected back to our app. On web, this is quite a straightforward process, the redirect URL simply points to our web app, e.g. https://epicfantasyforge.com/app. However on native apps, linking back to our app is not quite so straightforward.

To redirect back to our native apps, deep links are used. Deep-links allow an app to be opened with a link. To use deep-linking we need to register a protocol with the OS when our app is installed and/or run. In the case of Epic Fantasy Forge, the protocol is epic-fantasy-forge. To then open the Epic Fantasy Forge app using a deep link, the deep linking value would then need to use this custom protocol, e.g. epic-fantasy-forge://app.

GitLab Callback URL 1

Select "Authentication" from the sidebar on the left:

GitLab Callback URL 2

Select "Sign In / Providers":

GitLab Callback URL 3

Click on the OAuth provider in question (e.g. GitLab) from the list of "Auth Providers":

GitLab Callback URL 4

You can now see your callback URL. Copy it to your clipboard by clicking on the "Copy" button:

GitLab Callback URL 5

You can now paste the callback URL into the relevant OAuth's provider callback configuration. We will do this for each OAuth provider and for each environment, so in total 12 times (6 OAuth providers * 2 environments). For the instructions, see the following sections for each OAuth provider.

Whitelisting of Callback URLs

To redirect back to our app from Supabase after OAuth authentication, we need to whitelist our callback URLs. To do so, from your Supabase test environment's dashboard, click on the "Authentication" icon in the outer left sidebar and then "URL Configuration" on the inner left sidebar.

For the test environment you need to whitelist your test environment's app URL, e.g. https://test.epicfantasyforge.com/app in the case of Epic Fantasy Forge. Note that to test on your local development machine, you need to temporarily configure http://test.epicfantasyforge.com:4000/app instead.

For your production environment you need to configure your production environment's app URL, e.g. https://epicfantasyforge.com/app.

In the Redirect URLs section click "Add URL" and add your app's deep link URL, e.g. epic-fantasy-forge://app in the case of Epic Fantasy Forge. Note that we have not yet defined this custom protocol in our app, i.e. "epic-fantasy-forge" in the case of Epic Fantasy Forge. We will do this in a later section of this guide. This redirect URL needs to be configured in both your test and production environments.

Once you are done configuring your test environment, apply this configuration to your Supabase production environment also.

Supabase URL Whitelisting

Supabase URL Whitelisting 2

Supabase URL Whitelisting 3

GitLab

To add your application to GitLab for OAuth authentication, start by logging into your GitLab account. Then click on your profile icon on the left sidebar:

GitLab Application 1

Select "Edit profile" from the drop-down menu:

GitLab Application 2

Select "Applications" from the left sidebar:

GitLab Application 3

Click on "Add new application":

GitLab Application 4

Enter the details for your application. For "Name" enter your application's name. In the case of the test environment you may want to enter a name like "Epic Fantasy Forge Test". The production application will get the name "Epic Fantasy Forge". For the "Redirect URI", enter the URL you copied above in the Callback URL section. Check the "Confidential" checkbox. As we are using Supabase as an intermediary, we can keep the client secret truly secret, i.e. it is not stored in our apps but rather on the Supabase side. Check the checkbox "read_user".

Warning

When not using Supabase as an intermediary, you may not want to check the checkbox "Confidential". Only check this checkbox if you know what you are doing.

GitLab Application 5

Scroll down and click "Save application":

GitLab Application 6

Copy both the "Application ID" and "Secret" paste them into the appropriate fields in Supabase (next screenshot):

GitLab Application 7

In Supabase, paste the "Application ID" and "Secret" (that you copied above) into the appropriate fields. Enable the "GitLab enabled" switch. Then click "Save".

Warning

Do not paste or save the secret anywhere other than Supabase. The secret should not be stored in your version control or your application's source code.

Supabase GitLab

You should now have an application configured in GitLab:

GitLab Application 8

You should now have GitLab OAuth set up for your test environment. Now repeat the above steps for your production environment.

GitHub

To add your application to GitHub for OAuth authentication, start by logging into your GitHub account. Then go to GitHub Developer Settings and select "OAuth Apps" from the left sidebar. Then click on "New OAuth app":

GitHub Application 1

Enter your application's name (e.g. Epic Fantasy Forge Test), homepage (e.g. https://test.epicfantasyforge.com), application description and an "Authorization callback URL". To get the callback URL to paste, follow the instructions in the Callback URL section above. Then click "Register application":

GitHub Application 2

Next copy your "Client ID" and "Client secret":

GitHub Application 3

Paste the "Client ID" and "Client secret" fields you copied above into the appropriate fields in Supabase. Enable the "GitHub enabled" switch and then click "Save":

GitHub Application 4

Now let's add a logo for your application. Click on "Upload new logo":

GitHub Application 5

Select an appropriate logo for your application. You should already have a logo if you have followed the Logo page of this guide. Set the "Badge background color" to "#000000" to get a black background for your logo.

GitHub Application 6

Execute the above instructions for both your test and production environments. Finally you should have two OAuth apps registered in GitHub:

GitHub Application 7

Google

To add your application to Google for OAuth authentication, start by creating a Google Cloud account if you don't already have one. Set your country as appropriate, read through the terms of service and check the checkbox if you agree. Then click "Agree and continue":

Google Application 1

Once you have an account with Google Cloud, go to Google Cloud Console , select "Cloud overview" from the left sidebar and click on "Create project":

Google Application 2

Enter your project name, e.g. "Epic Fantasy Forge Test", and location. Then click "Create":

Google Application 3

Next select "APIs and services" from the left sidebar and click on "OAuth consent screen":

Google Application 4

Click on "Get started":

Google Application 5

Enter your "App name" and "User support email". For the user support email, it seems you need to use a Google email. At least I wasn't able to change it to "henrik@epicfantasyforge.com". When done click "Next":

Google Application 6

For "Audience", select "External". Then click "Next":

Google Application 7

In the "Email addresses" field, enter your project's contact email, e.g. "henrik@epicfantasyforge.com". Then click "Next":

Google Application 8

Read through the "Google API services user data policy". If you agree, check the checkbox and click "Continue" and/or "Create":

Google Application 9

Now select "Branding" from the left sidebar:

Google Application 10

Upload your app's logo:

Google Application 11

Next enter an "Authorised domain". Enter your Supabase callback URL for Google OAuth. To find your Supabase callback URL, follow the instructions in the Callback URL section above. For the "Email addresses" field in the "Developer contact information" section, enter your project's email address, e.g. "henrik@epicfantasyforge.com" in the case of Epic Fantasy Forge. Then click "Save":

Google Application 12

Now select "Data access" from the left sidebar and click "Add or remove scopes":

Google Application 13

Select the below three scopes and click "Update":

  • .../auth/userinfo.email
  • .../auth/userinfo.profile
  • openid

Google Application 14

The scopes you select above should now be visible in the "Your non-sensitive scopes" section. Now click "Save":

Google Application 15

Select "Clients" from the left sidebar and click "+ Create client":

Google Application 16

For "Application type" select "Web application". Enter your app's name in the "Name" field. For the field "Authorised JavaScript origins", enter your test environment's URL, e.g. "https://test.epicfantasyforge.com". For the "Authorised redirect URIs" field, enter your Supabase callback URL. To find your Supabase callback URL, follow the instructions in the Callback URL section above. Next click on "Create":

Google Application 17

Copy your "Client ID" and "Client secret" and paste them into the relevant fields in Supabase.

Google Application 18

Google Application Supabase

We will now make some small adjustments to the configuration. Select "Client" from the left sidebar and click on "+ Add URI". Enter the http URL of your test environment, e.g. "http://test.epicfantasyforge.com". This configuration is necessary to test Google OAuth with the web app running locally. Then click "Save":

Google Application 19

Now select "Branding" from the left sidebar and add links for your "Application home page", "Application privacy policy link" and "Application Terms of Service link". In the "Authorised domains" section, click on "+ Add domain" and enter your app's root domain, e.g. "epicfantasyforge.com". Then click "Save":

Google Application 20

Execute the above instructions for both your test and production environments. Finally you should have two projects configured in Google Cloud:

Google Application 21

Note that for testing purposes, we are now all set. However before going to production with Google OAuth, remember the following:

  • You need to create separate "Clients" in Google Cloud for every platform you are targeting
  • You need to verify your app

Apple

To add your application to Apple for OAuth authentication, start by logging into your Apple Developer Account account. Then scroll down to view your "Membership details". Make a note of your "Team ID" as you will need it later.

Apple Application 1

Next scroll back up to the "Program resources" section and click on "Service configuration":

Apple Application 2

Now click on "Configure" in the "Sign in with Apple for Email Communication" section:

Apple Application 3

Click on the "+" beside "Email Sources":

Apple Application 4

In the "Domains and Subdomains" field, enter your root domain, e.g. "epicfantasyforge.com" in the case of Epic Fantasy Forge. In the "Email Addresses" section enter an email address such as "authentication@epicfantasyforge.com". Then click "Next":

Apple Application 5

Click "Register":

Apple Application 6

Click "Done":

Apple Application 7

Now your domain and email address should be listed under "Email Sources":

Apple Application 8

Now return to your Apple Developer Account and click on "Identifiers":

Apple Application 9

Select "App IDs" from the drop-down menu on the right and then click the "+" beside "Identifiers":

Apple Application 10

Select "App IDs" and click "Continue":

Apple Application 11

Select "App" and click "Continue":

Apple Application 12

Enter a description for your App ID, e.g. "Epic Fantasy Forge Test". For "Bundle ID" select "Explicit" and enter a reverse domain style string for your app, e.g. "com.epicfantasyforge.test":

Apple Application 13

Scroll down and select "Sign in with Apple". Then click "Continue":

Apple Application 14

Click "Register":

Apple Application 15

You should now see your App ID Identifier listed:

Apple Application 16

Select "Services IDs" from the drop-down menu on the right and click on "Register an Services ID":

Apple Application 17

Select "Services IDs" and click "Continue":

Apple Application 18

Enter a name for your Services ID, e.g. "Epic Fantasy Forge Test". Enter a reverse-domain style string for the "Identifier" field, e.g. "com.epicfantasyforge.test.web". Then click "Continue":

Apple Application 19

Click "Register":

Apple Application 20

Your new Identifier for "Services IDs" should now be listed. Click on your new Services ID:

Apple Application 21

Click on "Configure" beside "Sign In with Apple":

Apple Application 22

Under "Primary App ID", select the App ID you created earlier. Under "Return URLs", enter your Supabase callback URL for Apple OAuth. To find your Supabase callback URL, follow the instructions in the Callback URL section above. For the field "Domains and Subdomains", enter the Supabase callback URL but with the protocol and path removed. Then click "Next":

Apple Application 23

Click "Done":

Apple Application 24

Now select "Keys" from the left sidebar and click on "Create a key":

Apple Application 25

Enter a "Key Name", e.g. "Epic Fantasy Forge Test". Select the "Sign in with Apple" checkbox and click "Configure" beside the "Sign in with Apple" checkbox:

Apple Application 26

Select the App ID you created earlier for the field "Primary App ID". Then click "Save":

Apple Application 27

Now click "Continue":

Apple Application 28

Click "Register":

Apple Application 29

Next click "Download" to download your key.

Warning

Keep your key secret and store it in a secure place.

Apple Application 30

Now you should be able to see your key listed under "Keys":

Apple Application 31

Now go to Supabase Login with Apple and scroll down to the section with the tool to generate an Apple client secret. In the "Account ID" field enter your Team ID. How to find your Team ID is covered at the beginning of the Apple section on this page. Enter the Service ID you created earlier in the "Service ID" field, e.g. "com.epicfantasyforge.test.web". Next upload your Apple key file that you downloaded from Apple Developer earlier. Then click on the "Generate Secret Key" button:

Apple Application 32

A secret key should now have been generated. Copy it to your clipboard:

Apple Application 33

Now paste your secret into the "Secret Key (for OAuth)" field in Supabase. Enter the Service ID you generated earlier in the field "Client IDs", e.g. "com.epicfantasyforge.test.web". Then enable the "Enable Sign in with Apple" switch and click "Save".

Apple Application 34

You should now have Apple OAuth set up for your test environment. Now repeat the above instructions for your production environment.

Warning

Note that the Apple key expires after 6 months. Remember to renew it before it expires.

Microsoft

To add your application to Microsoft for OAuth authentication, create an account on Microsoft Azure if you don't already have one. Then on your Microsoft Azure dashboard, click on "More services":

Microsoft Application 1

Search for Entra in the search box and click on Microsoft Entra ID in the search results:

Microsoft Application 2

On the left sidebar, click on App registrations:

Microsoft Application 3

Click on + New registration:

Microsoft Application 4

Enter an application name, e.g. Epic Fantasy Forge Test and select the Accounts in any organizational directory and personal Microsoft accounts checkbox for the Supported account types. In the Redirect URI section, select Web from the dropdown menu and enter the Supabase callback URL for Azure (Microsoft) OAuth. To find your Supabase callback URL, follow the instructions in the Callback URL section above. Then click Register:

Microsoft Application 5

Now your app registration should have been created. Make note of your Application (client) ID and store it in a secure place.

Microsoft Application 6

Next open the Manage accordion on the left sidebar and select Certificates & secrets. Then click + New client secret:

Microsoft Application 7

Enter an appropriate Description and select your preferred expiration time from the Expires drop-down menu. In the case of Epic Fantasy Forge and expiry of 730 days was selected. Then click Add:

Microsoft Application 8

Now your client secret should have been created. Make note of the Value of your client secret and store it in a secure place.

Microsoft Application 9

Now paste your client secret value into the Secret Value field in Supabase. Enter the Application (client) ID value you generated earlier in the field Application (client) ID in Supabase. Then enable the "Azure enabled" switch and click "Save".

Microsoft Application 10

Back on the Microsoft Azure Certificates & secrets page, select Manifest from the left sidebar:

Microsoft Application 11

Edit your Manifest JSON according to the instructions by Supabase on the Guarding against unverified email domains section of the Microsoft Azure OAuth page on Supabase. You need to edit the value of the optionalClaims key to be as follows:

"optionalClaims": {
      "idToken": [
          {
              "name": "xms_edov",
              "source": null,
              "essential": false,
              "additionalProperties": []
          },
          {
              "name": "email",
              "source": null,
              "essential": false,
              "additionalProperties": []
          }
      ],
      "accessToken": [
          {
              "name": "xms_edov",
              "source": null,
              "essential": false,
              "additionalProperties": []
          }
      ],
      "saml2Token": []
  },

Microsoft Application 12

You should now have Microsoft OAuth set up for your test environment. Now repeat the above instructions for your production environment.

Discord

To add your application to Discord for OAuth authentication, create an account on Discord if you don't already have one. Then go to Discord Developer Home. Select Application from the left sidebar and then click New Application:

Discord Application 1

Enter an application name, e.g. Epic Fantasy Forge Test, read through the Developer Terms of Service and Developer Policy, and if you agree check the checkbox and click Create:

Discord Application 2

Next upload an icon for your app and optionally fill the description field. Then click Save Changes:

Discord Application 3

Next select OAuth2 from the left sidebar and make a note of your CLEINT ID and save it in a secure place. Enter your Supabase callback URL for Discord OAuth in the Redirects field. To find your Supabase callback URL, follow the instructions in the Callback URL section above. Then click Save Changes:

Discord Application 4

Now click on Reset Secret:

Discord Application 5

On the prompt, click Yes, do it!:

Discord Application 6

Enter the Multi-Factor Authentication that you received and click Submit:

Discord Application 7

Now make note of your CLIENT SECRET and store it in a secure place.

Discord Application 8

Now paste your client secret value into the Client Secret field in Supabase. Enter the Client ID you noted down earlier in the Client ID field. Then enable the "Discord enabled" switch and click "Save".

Discord Application 9

You should now have Discord OAuth set up for your test environment. Now repeat the above instructions for your production environment.

OTP

When users login using OTP, they will be sent a one-time password (OTP) to their email address. They need to enter this 6 digit OTP code in the app to be logged in. OTP is a form of passwordless login where users do not need to set or remember a password.

Supabase can also send a magic link instead of a 6 digit code, however for Epic Fantasy Forge the 6 digit code was chosen as it is platform agnostic. Whilst the magic link is also platform agnostic for the web app, for the native apps (e.g. Windows, Android, etc.) it would rely on the deep-link protocol registration to have been successful when the app was installed. A deep link can open an app instead of a web page, however this functionality relies on the OS.

Email

To use OTP in Supabase, from your test environment's dashboard, click the Authentication icon from the outer left sidebar, select Sign In / Providers from the CONFIGURATION section on the inner left sidebar and then click on Email from the Auth Providers:

Email 1

Enable the Enable Email provder switch. For enhanced security also enable the Secure email change switch. On Epic Fantasy Forge, password based authentication is not enabled at all, so the password related fields are irrelevant unless you plan to use password based authentication in your app. For the Email OTP Expiration enter a value of 900. This ensures that the OTP code expires after 15 minutes. Then click Save:

Email 2

On the inner left sidebar in the CONFIGURATION section, select Emails and select the Magic link tab. Enter Login Code as the Subject heading and replace the Message body with:

<head>
  <meta charset="UTF-8" />
  <title>Login Code</title>
  <style>
    body {
      font-family: Roboto, sans-serif;
      margin: 0;
      min-height: 100vh;
      background: linear-gradient(135deg, #312e81 0%, #111827 50%, #701a75 100%);
      color: #f3f4f6;
    }
    .container {
      max-width: 400px;
      margin: 48px auto;
      background: rgba(17, 24, 39, 0.96);
      border-radius: 16px;
      box-shadow: 0 8px 32px 0 rgba(0,0,0,0.18);
      border: 1px solid rgba(255,255,255,0.10);
      padding: 40px 32px;
      backdrop-filter: blur(8px);
      overflow: hidden;
      position: relative;
    }
    h2 {
      color: #f3f4f6;
      text-align: center;
      font-size: 1.5em;
      font-weight: bold;
      margin-bottom: 16px;
      letter-spacing: -0.01em;
    }
    .otp {
      font-size: 2em;
      letter-spacing: 0.2em;
      margin: 24px 0;
      color: #a5b4fc;
      text-align: center;
      font-weight: bold;
    }
    p {
      color: #e5e7eb;
      text-align: center;
      margin-bottom: 16px;
    }
  </style>
</head>
<body>
  <div class="container">
    <h2>Login Code</h2>
    <div class="otp">{{ .Token }}</div>
    <p>This code will expire in 15 minutes.</p>
  </div>
</body>

Then click Save changes:

Email 3

With the HTML template set above, the email containing the OTP code should look something like the below. Depending on the email client used, the email may look different.

Tip

It is not recommended to include images in your HTML template as most email clients block these by default. The email template above used by Epic Fantasy Forge does not rely on images so it should display correctly with most email clients on default settings.

OTP Email

Now select the SMTP Settings tab:

Email 4

Whilst Supabase provides a simple SMTP server, it is not recommended for production use. We will need to use our own SMTP server. For this purpose we will use Proton Mail. If you have followed the Email section on the Contact page of this guide, then you should already have an account with Proton Mail. Once you have logged into your Proton Mail account, click on the Settings gear icon in the top right:

Email 4.5

Click on All settings:

Email 5

From the left sidebar select Identity and addresses from the Proton Mail section. Then click on Add address:

Email 6

Enter an email address from which your OTP emails will be sent, e.g. authentication@epicfantasyforge.com. Then enter a display name, e.g. Epic Fantasy Forge. Then click Save address:

Email 7

Your new email address should now be listed:

Email 8

On the left sidebar select IMAP/SMTP and click on Generate token:

Email 9

Enter a token name, e.g. Supabase and select an email address to use with the token. Set the email address to the one we created earlier, e.g. authentication@epicfantasyforge.com. Then click Generate:

Email 10

Note down the SMTP token details and store them in a secure place. Then click Close:

Email 11

Back on the Supabase SMTP Settings screen, enter the Proton Mail token details that you noted down earlier:

Email 12

For the Minimum interval between emails being sent to same user field, enter a value such as 60 seconds. This helps to prevent abuse. For the Username and Password fields, enter the SMTP username and SMTP token respectively from the details you noted down earlier on the Proton Mail Your SMTP token modal. Then clic Save changes.

Tip

When testing the OTP code, you can temporarily lower the Minimum interval between emails being sent to same user to for example 1 second for your test environment to make testing more convenient. Just remember to restore it afterwards.

Email 13

You should now have OTP set up for your test environment. Now repeat the above instructions for your production environment. Note that you only need to repeat the steps in Supabase, not in Proton Mail. In Epic Fantasy Forge, no separate email was created for sending OTP emails in the production environment, i.e. the same Proton Mail email is used to send OTP codes both in the test and production environments.

Dependencies

To enable user authentication in our app, we will rely on some third-party dependencies.

Web

In your assets directory of your Phoenix project, run the below command to install a necessary dependency:

npm install topbar

Since we now have a non-dev dependency in our package.json, we need to update the auto-generated Dockerfile to install this dependency when creating the Docker image. We had previously generated this Dockerfile in the Deployment of Test Environment section on the Database page of this guide.

Update the RUN apt-get update line in the # install build dependencies section in your Dockerfile to the below line. We now install npm also:

Dockerfile
RUN apt-get update -y && apt-get install -y build-essential git npm wget \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

After the COPY assets assets line, at the below block to your Dockerfile:

Dockerfile
# install npm dependencies
WORKDIR /app/assets
RUN npm ci

Add the below line as the first line in the # compile assets section to restore the working directory:

Dockerfile
WORKDIR /app

Now run the below command in the assets directory of your Phoenix project to install some dependencies needed by tests:

npm install --save-dev @types/phoenix @types/phoenix_live_view

Add the below dependencies into the deps array in mix.exs located in the root directory of your Phoenix project:

mix.exs
{:supabase_potion, "0.7.1"},
{:supabase_auth, "0.6.2"},
{:mox, "1.2.0", only: :test},
{:lazy_html, "0.1.8", only: :test}

Then run the below command to install the new dependencies:

mix deps.get

App

Add these dependencies to your Tauri project by running the below command in the src-tauri directory of your Tauri project:

cargo add anyhow blake3 mockall oauth2 rand rust-argon2 tauri-plugin-deep-link tauri-plugin-stronghold url

Manually add the below to [Cargo.toml]{:target="_blank"} in the [dependencies] section:

Cargo.tom
supabase-auth = { git = "https://github.com/supabase-community/supabase-auth-rs.git", branch = "main", default-features = false, features = ["use-rustls"] }

Tip

We need to use the main branch version of supabase-auth as at the time of writing this a feature needed by Epic Fantasy Forge (PKCE) has not yet been published to the package repository but is already available in the main branch.

Additionally add this development dependency:

cargo add uuid --dev

Manually append the below lines to [Cargo.toml]{:target="_blank"}:

Cargo.toml
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }

[profile.dev.package.scrypt]
opt-level = 3

Tip

The opt-level 3 for scrypt is required as otherwise the Stronghold Tauri plugin is very slow in development builds.

Remember to add these new dependencies to your list of third-party dependencies in third_party.html.heex. Please see the Third-party page in this guide for more information.

Assets

Web

In the priv/static/images directory of your Phoenix project, create a new directory named icons. Download the Risk Icon from uxwing and place it inside the new directory. Rename the file to have the name warning.svg.

Next create a new directory named logos in the priv/static/images directory of your Phoenix project. Inside this new directory place the logos of the below OAuth providers. In the table below you can find what file names you should give these logos and from where they can be downloaded:

OAuth Provider Download location File name
GitLab GitLab Logo gitlab.png
Apple Apple Logo apple.png
Microsoft Microsoft Logo microsoft.svg
Discord Discord Logo discord.svg

GitHub and Google are missing from the table above as we use the SVG code of those logos directly inline in our code in +page.svelte. You can see the code in the Production Code section below. For the Google logo, the source of the SVG is the Sign in with Google Branding Guidelines page. The page includes a generator to generate the SVG of the logo.

Remember to add these new icons and logos to your list of third-party dependencies in third_party.html.heex. Please see the Third-party page in this guide for more information.

App

In the static directory of your Tauri project, create a new directory named icons. Download the Risk Icon from uxwing and place it inside the new directory. Rename the file to have the name warning.svg.

Copy a relatively high resolution version of your logo into the static directory of your Tauri project and name the file logo.png. For Epic Fantasy Forge a 256x256 version of the logo is used. If you have followed the Logo page of this guide then you should have already generated a logo previously.

Next create a new directory named logos in the static directory of your Tauri project. Inside this new directory place the logos of the below OAuth providers. In the table below you can find what file names you should give these logos and from where they can be downloaded:

OAuth Provider Download location File name
GitLab GitLab Logo gitlab.png
Apple Apple Logo apple.png
Microsoft Microsoft Logo microsoft.svg
Discord Discord Logo discord.svg

GitHub and Google are missing from the table above as we use the SVG code of those logos directly inline in our code in +page.svelte. You can see the code in the Production Code section below. For the Google logo, the source of the SVG is the Sign in with Google Branding Guidelines page. The page includes a generator to generate the SVG of the logo.

Remember to add these new icons and logos to your list of third-party dependencies in third_party.html.heex. Please see the Third-party page in this guide for more information.

Configuration

Web

When deploying to our test and production environments, we will now need to provide the Supabase URL and Supabase anonymous key as environment variables to our Phoenix web application. You can find these two items from Supabase. Go to your Supabase project's dashboard and click Connect:

Supabase Connection Information 1

Select the App Frameworks tab and note down the NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY. We are not using Next.js however in this case the framework selection does not matter, we are only interested in the values of these two environment variables, not the names. Note that I have censored the Supabase anon key in the screenshot, however according to the Supabase API Keys Documentation, the anon key is safe to expose online.

Supabase Connection Information 2

Now add these values as CI variables to GitLab. Create a new variable with the Key set to SUPABASE_URL_TEST and the Value set to the URL you retrieved from Supabase above. Then click Add variable:

Supabase CI Variables 1

Create a new variable with the Key set to SUPABASE_API_KEY_TEST and the Value set to the key you retrieved from Supabase above. Make sure to select Masked and hidden for the Visibility selection. Then click Add variable:

Supabase CI Variables 2

Now repeat the above steps for your production environment:

Supabase CI Variables 3

Supabase CI Variables 4

Now that these values are in the CI, update your deploy-test-environment.yml to pass these new environment variables:

deploy-test-environment.yml
deploy-test-environment:
  before_script:
    - cat $CI_PRIVATE_KEY | base64 -d > ~/ci_private_key
    - chmod og= ~/ci_private_key
    - export CI_PRIVATE_KEY=~/ci_private_key
    - dnf install -y openssh-clients
  environment:
    name: Test
    url: https://test.epicfantasyforge.com
  image: fedora:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  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 $CI_REGISTRY_IMAGE:test-environment"
  stage: deploy-test-environment

Now also update deploy-production-environment.yml to pass these new environment variables:

deploy-production-environment.yml
deploy-production-environment:
  before_script:
    - cat $CI_PRIVATE_KEY | base64 -d > ~/ci_private_key
    - chmod og= ~/ci_private_key
    - export CI_PRIVATE_KEY=~/ci_private_key
    - dnf install -y openssh-clients
  environment:
    name: Production
    url: https://epicfantasyforge.com
  image: fedora:latest
  rules:
    - if: $RELEASE == "Web"
  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 $CI_REGISTRY_IMAGE:production-environment"
  stage: deploy-production-environment

Replace the contents of eslint.config.mjs located in the assets directory in your Phoenix project with the below content:

eslint.config.mjs
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  tseslint.configs.recommended,
  {
    languageOptions: {
      globals: {
        document: 'readonly'
      }
    }
  }
);

Replace the contents of tailwind.config.js located in the assets directory in your Phoenix project with the below content:

tailwind.config.js
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")

module.exports = {
  content: [
    "./js/**/*.js",
    "../lib/epic_fantasy_forge_web.ex",
    "../lib/epic_fantasy_forge_web/**/*.*ex"
  ],
  theme: {
    extend: {
      animation: {
        'modal': 'modal 0.75s cubic-bezier(0.4,0,0.2,1)',
        'toast': 'toast 5s cubic-bezier(0.4,0,0.2,1)'
      },
      colors: {
        brand: "#FD4F00",
      },
      fontFamily: {
        orbitron: ['"Orbitron"', 'sans-serif']
      },
      keyframes: {
        'modal': {
          '0%': {
            opacity: 0,
            transform: 'translateX(0) translateY(-60%) scale(0.98)'
          },
          '60%': {
            opacity: 1,
            transform: 'translateX(0) translateY(8px) scale(1.01)'
          },
          '80%': {
            transform: 'translateX(0) translateY(0) scale(1.01)'
          },
          '100%': {
            opacity: 1,
            transform: 'translateX(0) translateY(0) scale(1)'
          }
        },
        'toast': {
          '0%': {
            opacity: 0,
            transform: 'translateX(-50%) translateY(-60%) scale(0.98)'
          },
          '6%': {
            opacity: 1,
            transform: 'translateX(-50%) translateY(8px) scale(1.01)'
          },
          '8%': {
            transform: 'translateX(-50%) translateY(0) scale(1.01)'
          },
          '10%': {
            opacity: 1,
            transform: 'translateX(-50%) translateY(0) scale(1)'
          },
          '90%': {
            opacity: 1,
            transform: 'translateX(-50%) translateY(0) scale(1)'
          },
          '100%': {
            opacity: 0,
            transform: 'translateX(-50%) translateY(-60%) scale(0.98)'
          }
        }
      }
    },
  },
  plugins: [
    require("@tailwindcss/forms"),

    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),

    plugin(function({matchComponents, theme}) {
      let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
      let values = {}
      let icons = [
        ["", "/24/outline"],
        ["-solid", "/24/solid"],
        ["-mini", "/20/solid"],
        ["-micro", "/16/solid"]
      ]
      icons.forEach(([suffix, dir]) => {
        fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
          let name = path.basename(file, ".svg") + suffix
          values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
        })
      })
      matchComponents({
        "hero": ({name, fullPath}) => {
          let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
          let size = theme("spacing.6")
          if (name.endsWith("-mini")) {
            size = theme("spacing.5")
          } else if (name.endsWith("-micro")) {
            size = theme("spacing.4")
          }
          return {
            [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
            "-webkit-mask": `var(--hero-${name})`,
            "mask": `var(--hero-${name})`,
            "mask-repeat": "no-repeat",
            "background-color": "currentColor",
            "vertical-align": "middle",
            "display": "inline-block",
            "width": size,
            "height": size
          }
        }
      }, {values})
    })
  ]
}

Add the below configuration block to config.exs located in the config directory of your Phoenix project. Place the new configuration just above the last import_config block:

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

config :epic_fantasy_forge, :supabase_go_true_api, Supabase.GoTrue

Add the below line to dev.exs located in the config directory of your Tauri project. Place the new line in the config :epic_fantasy_forge, EpicFantasyForgeWeb.Endpoint, block:

config.exs
session_key: "_epic_fantasy_forge_dev_key",

Add the below configuration block to runtime.exs located in the config directory of your Phoenix project. Place the new configuration block after the first import:

runtime.exs
config :epic_fantasy_forge, EpicFantasyForge.Supabase.Client,
  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_API_KEY") ||
      raise("""
      Environment variable SUPABASE_API_KEY is missing.
      You can find it in your Supabase project settings.
      """),
  auth: %{
    flow_type: :pkce
  }

config :supabase_auth, auth_module: EpicFantasyForgeWeb.Auth

Replace the line config :logger, level: :warning in [test.exs]{:target="_blank"} located in the config directory of your Phoenix project with the below line:

test.exs
config :logger, level: :none

App

Our app will now need the environment variables SUPABASE_URL and SUPABASE_API_KEY to be set at build time. If you have followed the Configuration section for web above, then you should already have the below environment variables defined in GitLab:

  • SUPABASE_URL_TEST
  • SUPABASE_URL_PRODUCTION
  • SUPABASE_API_KEY_TEST
  • SUPABASE_API_KEY_PRODUCTION

We now need to map the above keys to the SUPABASE_URL and SUPABASE_API_KEY environment variables in the CI. To do so, add the below code to the beginning of the before_script sections for the following CI jobs:

before_script:
  - |
    if [ -n "$RELEASE" ]; then
      export SUPABASE_URL="$SUPABASE_URL_PRODUCTION"
      export SUPABASE_API_KEY="$SUPABASE_API_KEY_PRODUCTION"
    else
      export SUPABASE_URL="$SUPABASE_URL_TEST"
      export SUPABASE_API_KEY="$SUPABASE_API_KEY_TEST"
    fi

If the before_script section does not yet exist in some of the CI jobs then add it. In the case of iOS, add the additional below code to build-ios.yml right after the code that you just added above:

build-ios.yml
- |
  mkdir -p app/.cargo
  cat > app/.cargo/config.toml <<EOF
  [env]
  SUPABASE_URL = "${SUPABASE_URL}"
  SUPABASE_API_KEY = "${SUPABASE_API_KEY}"
  EOF

Now your native apps should use the Supabase test environment for regular build and the Supabase production environment for builds that are built during a release.

To support deep-linking in our Tauri app we need to add the below two permissions to default.json located at src-tauri/capabilities:

default.json
"core:event:default",
"deep-link:default",

Additionally add the below configuration block to tauri.conf.json:

tauri.conf.json
"plugins": {
  "deep-link": {
    "desktop": {
      "schemes": ["epic-fantasy-forge"]
    },
    "mobile": [
      {
        "scheme": ["epic-fantasy-forge"],
        "appLink": false
      }
    ]
  }
}

This registers the custom protocol epic-fantasy-forge to point to our app.

Tests

Local Testing

To test OAuth locally, you need to temporarily edit your hosts file located in /etc to map the domains epicfantasyforge.com and test.epicfantasyforge.com to 127.0.0.1:

hosts
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4 epicfantasyforge.com test.epicfantasyforge.com

When you now try to access epicfantasyforge.com or test.epicfantasyforge.com it will not go to the cloud version running in Hetzner but instead to your locally running instance. This is necessary as in Supabase it is not possible to configure localhost as a whitelisted callback URL. For configuring callback URLs see the Whitelisting of Callback URLs section above. Remember to undo this change when you are done with local testing.

E2E Test

We will add a manual E2E test for the authentication feature to Qase. Add a new test case to the Common suite named Authentication.

Authentication Test 1

Authentication Test 2

Authentication Test 3

Automated Tests

Web

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

code-input.test.ts
import "@testing-library/jest-dom";
import { initializeCodeInput } from "../ts/code-input";

describe("Code Input", () => {
  const numberOfDigits = 6;

  let inputs: HTMLInputElement[];
  let container: HTMLDivElement;

  beforeEach(() => {
    container = document.createElement("div");
    inputs = [];

    for (let index = 0; index < numberOfDigits; index++) {
      const input = document.createElement("input");
      input.setAttribute("otp-digit", "");

      inputs.push(input);
      container.appendChild(input);
    }

    document.body.appendChild(container);
    initializeCodeInput(container);
  });

  test("works on pages without code input", () => {
    container.innerHTML = ``;

    expect(() => initializeCodeInput(container)).not.toThrow();
  });

  test("resets input field on non-digit input", () => {
    wheneverInputIs(0, "a");

    expect(inputs[0].value).toBe("");
  });

  test("no inputs focused when digit entered into last input box", () => {
    wheneverInputIs(numberOfDigits - 1, "0");

    inputs.forEach(input => {
      expect(input).not.toHaveFocus();
    });
  });

  test("next input field is focused on digit entry", () => {
    wheneverInputIs(0, "0");

    expect(inputs[1]).toHaveFocus();
  });

  test("previous input field is not focused on non-backspace key press in input box", () => {
    wheneverKeyIsPressed(1, "Enter");

    expect(inputs[0]).not.toHaveFocus();
    expect(inputs[1]).toHaveFocus();
  });

  test("previous input field is not focused on backspace key press in first input box", () => {
    wheneverKeyIsPressed(0, "Backspace");

    expect(inputs[0]).toHaveFocus();
  });

  test("previous input field is focused on backspace key press", () => {
    wheneverKeyIsPressed(1, "Backspace");

    expect(inputs[0]).toHaveFocus();
  });

  test("resets input field on focus", () => {
    inputs[0].value = "a";
    inputs[0].focus();

    expect(inputs[0].value).toBe("");
  });

  function wheneverInputIs(index: number, value: string) {
    inputs[index].focus();
    inputs[index].value = value;
    inputs[index].dispatchEvent(new Event("input", { bubbles: true }));
  }

  function wheneverKeyIsPressed(index: number, key: string) {
    inputs[index].focus();
    inputs[index].dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
  }
});

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

code-o-auth-redirect.test.ts
import "@testing-library/jest-dom";
import { handleOAuthRedirectEvent } from "../ts/o-auth-redirect";

describe("OAuthRedirect", () => {
  const originalLocation = window.location;
  const redirectUrl = "https://epicfantasyforge.com/app";
  const codeVerifier = "Epic Fantasy Forge";

  let consoleErrorSpy: jest.SpyInstance;
  let hasRaisedError: boolean;

  const onClientError = () => {
      hasRaisedError = true;
  };

  beforeAll(() => {
    consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
  });

  beforeEach(() => {
    // @ts-expect-error: Mocked for testing purposes
    delete window.location;
    // @ts-expect-error: Mocked for testing purposes
    window.location = { href: "" };

    hasRaisedError = false;
    window.addEventListener("client-error", onClientError);
  });

  afterEach(() => {
    // @ts-expect-error: Mocked for testing purposes
    window.location = originalLocation;
    window.removeEventListener("client-error", onClientError);
  });

  afterAll(() => {
    consoleErrorSpy.mockRestore();
  });

  test("redirects when saving code_verifier succeeds", async () => {
    globalThis.fetch = jest.fn().mockResolvedValue({ ok: true });

    await handleOAuthRedirectEvent({ url: redirectUrl, code_verifier: codeVerifier });

    expect(window.location.href).toBe(redirectUrl);
    expect(hasRaisedError).toBe(false);
  });

  test("does not redirect when saving code_verifier fails", async () => {
    globalThis.fetch = jest.fn().mockResolvedValue({ ok: false });

    await handleOAuthRedirectEvent({ url: redirectUrl, code_verifier: codeVerifier });

    expect(window.location.href).toBe("");
    expect(hasRaisedError).toBe(true);
  });

  test("does not redirect when saving code_verifier throws", async () => {
    globalThis.fetch = jest.fn().mockRejectedValue(new Error(""));

    await handleOAuthRedirectEvent({ url: redirectUrl, code_verifier: codeVerifier });

    expect(window.location.href).toBe("");
    expect(hasRaisedError).toBe(true);
  });
});

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

toast.test.ts
import "@testing-library/jest-dom";
import { waitFor } from "@testing-library/dom";
import { initializeToast } from "../ts/toast";

describe("Toast", () => {
  const animationEndKey = "animationend";
  const hiddenClass = "hidden";
  const timeout = 5000;

  const toastId = {
    info: "toast-info",
    error: "toast-error",
  };

  let toastInfo: HTMLElement | null;
  let toastError: HTMLElement | null;

  beforeEach(() => {
    document.body.innerHTML = `
      <div id="${toastId.info}"></div>
      <div id="${toastId.error}"></div>
    `;

    toastInfo = document.getElementById(toastId.info) as HTMLDivElement;
    toastError = document.getElementById(toastId.error) as HTMLDivElement;
  });

  test("works on pages without toast", () => {
    document.body.innerHTML = ``;

    expect(() => initializeToast()).not.toThrow();
  });

  test("shows toast initially", () => {
    initializeToast();

    expect(toastInfo).not.toHaveClass(hiddenClass);
    expect(toastError).not.toHaveClass(hiddenClass);
  });

  test("hides toast after animation ends", async () => {
    initializeToast();
    toastInfo!.dispatchEvent(new Event(animationEndKey));
    toastError!.dispatchEvent(new Event(animationEndKey));

    await waitFor(() => {
      expect(toastInfo).toHaveClass(hiddenClass);
      expect(toastError).toHaveClass(hiddenClass);
    }, { timeout: timeout });
  });
});

Modify the three test files error_html_test.exs, error_json_test.exs and page_controller_test.exs located in the directory test/epic_fantasy_forge_web/controllers to have the below as the second line:

@moduledoc false

Create a new file named session_controller_test.exs in the directory test/epic_fantasy_forge_web/controllers and populate it with the below content:

session_controller_test.exs
defmodule EpicFantasyForgeWeb.SessionControllerTest do
  @moduledoc false
  use EpicFantasyForgeWeb.ConnCase, async: true

  @test_verifier "lszaJtkFnR7X8sCwgolkLaH7LvpYvqZL"

  test "can set and retrieve code_verifier", %{conn: conn} do
    conn = post(conn, "/api/session", %{"code_verifier" => @test_verifier})

    assert conn.status == 204
    assert conn.resp_body == ""
    assert get_session(conn, :code_verifier) == @test_verifier
  end

  test "cannot set invalid key", %{conn: conn} do
    allowed_keys =
      EpicFantasyForgeWeb.SessionController.allowed_keys()
      |> Enum.join(", ")

    conn = post(conn, "/api/session", %{"invalid_key" => "invalid_value"})

    assert conn.status == 400

    assert conn.resp_body ==
             "No valid key provided. The allowed keys are: #{allowed_keys}"

    assert get_session(conn, :invalid_key) == nil
  end
end

In the directory test/epic_fantasy_forge_web create a new directory named live. Inside this new directory create a new file named authentication_test.exs and populate it with the below content:

authentication_test.exs
defmodule EpicFantasyForgeWeb.AuthenticationTest do
  @moduledoc false
  use EpicFantasyForgeWeb.ConnCase
  import Mox
  import Phoenix.LiveViewTest

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

  alias EpicFantasyForgeWeb.TestOAuthAtoms
  alias EpicFantasyForgeWeb.TestUtilities

  setup :verify_on_exit!

  @error "Login failed"
  @success "Logged in"

  @path "/app"

  test "shows error on client error", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_oauth_login_succeeds()

    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "login_with_oauth", %{
      provider: TestOAuthAtoms.provider()
    })

    render_hook(view, "client_error", %{error: @error})

    expected_url = TestOAuthAtoms.oauth_url()
    expected_code_verifier = TestOAuthAtoms.code_verifier()

    assert_push_event(view, "o-auth-redirect", %{
      url: ^expected_url,
      code_verifier: ^expected_code_verifier
    })

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

  test "does not show status indicators when code not present", %{conn: conn} do
    {:ok, view, _html} = live(conn, @path, session: nil)

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

  test "shows error when getting Supabase client for code exchange fails", %{
    conn: conn
  } do
    TestUtilities.when_get_client_fails()

    code = TestOAuthAtoms.code()

    {:error,
     {:live_redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
      live(conn, "#{@path}?code=#{code}")

    assert error_message == @error
    assert redirect_path == @path
  end

  test "redirects when getting Supabase client for code exchange fails", %{
    conn: conn
  } do
    TestUtilities.when_get_client_fails()

    conn = get(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
    assert redirected_to(conn) == @path
  end

  test "shows error when code exchange fails", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_code_exchange_fails()

    {:error,
     {:live_redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
      live(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")

    assert error_message == @error
    assert redirect_path == @path
  end

  test "redirects when code exchange fails", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_code_exchange_fails()

    conn = get(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
    assert redirected_to(conn) == @path
  end

  test "redirects when error URL query parameter present", %{conn: conn} do
    {:error,
     {:live_redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
      live(conn, "#{@path}?error=error")

    assert error_message == @error
    assert redirect_path == @path
  end

  test "shows success when code exchange is successful", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_code_exchange_succeeds()

    {:error,
     {:live_redirect, %{to: redirect_path, flash: %{"info" => info_message}}}} =
      live(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")

    assert info_message == @success
    assert redirect_path == @path
  end

  test "redirects when code exchange is successful", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_code_exchange_succeeds()

    conn = get(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
    assert redirected_to(conn) == @path
  end

  test "shows warning modal when user attempts to continue without account", %{
    conn: conn
  } do
    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "continue_without_account")

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

  test "dismisses warning modal when user confirms to continue without account",
       %{conn: conn} do
    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "continue_without_account")
    render_click(view, "confirm_without_account")

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

  test "dismisses warning modal when user decides to use account", %{conn: conn} do
    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "continue_without_account")
    render_click(view, "use_account")

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

Create a new file named oauth_test.exs in the directory test/epic_fantasy_forge_web/live and populate it with the below content:

oauth_test.exs
defmodule EpicFantasyForgeWeb.OAuthTest do
  @moduledoc false
  use EpicFantasyForgeWeb.ConnCase
  import Mox
  import Phoenix.LiveViewTest

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

  alias EpicFantasyForgeWeb.TestOAuthAtoms
  alias EpicFantasyForgeWeb.TestUtilities

  @path "/app"

  setup :verify_on_exit!

  test "shows error when provider is invalid", %{conn: conn} do
    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "login_with_oauth", %{provider: "invalid"})

    refute_push_event(view, "o-auth-redirect", %{
      url: _,
      code_verifier: _
    })

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

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

    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "login_with_oauth", %{
      provider: TestOAuthAtoms.provider()
    })

    refute_push_event(view, "o-auth-redirect", %{
      url: _,
      code_verifier: _
    })

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

  test "shows error when OAuth login initialization fails", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_oauth_login_fails()

    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "login_with_oauth", %{
      provider: TestOAuthAtoms.provider()
    })

    refute_push_event(view, "o-auth-redirect", %{
      url: _,
      code_verifier: _
    })

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

  test "redirects when OAuth login initialization succeeds", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_oauth_login_succeeds()

    {:ok, view, _html} = live(conn, @path, session: nil)

    render_click(view, "login_with_oauth", %{
      provider: TestOAuthAtoms.provider()
    })

    expected_url = TestOAuthAtoms.oauth_url()
    expected_code_verifier = TestOAuthAtoms.code_verifier()

    assert_push_event(view, "o-auth-redirect", %{
      url: ^expected_url,
      code_verifier: ^expected_code_verifier
    })

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

Create a new file named otp_test.exs in the directory test/epic_fantasy_forge_web/live and populate it with the below content:

otp_test.exs
defmodule EpicFantasyForgeWeb.OTPTest do
  @moduledoc false
  use EpicFantasyForgeWeb.ConnCase
  import Mox
  import Phoenix.LiveViewTest

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

  alias EpicFantasyForgeWeb.TestUtilities

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

  setup :verify_on_exit!

  test "shows error when getting Supabase client for login with OTP fails", %{
    conn: conn
  } do
    TestUtilities.when_get_client_fails()

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

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

  test "shows error when login with OTP fails", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_otp_login_fails()

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

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

  test "shows login code input when login with OTP succeeds", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_otp_login_succeeds()

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

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

  test "shows error when getting Supabase client for OTP code verification fails",
       %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_otp_login_succeeds()
    TestUtilities.when_get_client_fails()

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

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

  test "shows error when OTP code verification fails", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_otp_login_succeeds()
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_verification_fails()

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

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

  test "shows success banner when OTP code verification succeeds", %{conn: conn} do
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_otp_login_succeeds()
    TestUtilities.when_get_client_succeeds()
    TestUtilities.when_verification_succeeds()

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

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

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

oauth_atoms.ex
defmodule EpicFantasyForgeWeb.TestOAuthAtoms do
  @moduledoc false

  @code "azv7hm2n-4m21-1wev-fn2f-p49kw07p6ih7"
  @code_verifier "paVNwolVlKkrnFXyAIGDwboQiIcBUuXOod2RsGTj2S9mHOnXUwuYJuvb"
  @oauth_url "https://dxupnrgexypxfbhucpgi.supabase.co/auth/v1/authorize?code_challenge=If91BQRQhvZ7S6tEmgbZY00OezEO3Wt6LJVv9uVD1iJ&code_challenge_method=s256&provider=gitlab&redirect_to=https://epicfantasyforge.com/app"
  @provider "gitlab"

  @oauth_credentials %{
    provider: @provider,
    options: %{
      redirect_to: "https://localhost/app"
    }
  }

  def code, do: @code
  def code_verifier, do: @code_verifier
  def oauth_url, do: @oauth_url
  def provider, do: @provider
  def oauth_credentials, do: @oauth_credentials
end

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

test_utilities.exs
defmodule EpicFantasyForgeWeb.TestUtilities do
  @moduledoc false
  import Mox

  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
  }

  @session %{
    token: "lwn3j89n",
    refresh_token: "cu6dbswg"
  }

  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.SupabaseGoTrueAPIMock,
      :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.SupabaseGoTrueAPIMock,
      :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.SupabaseGoTrueAPIMock,
      :sign_in_with_otp,
      fn _client, @otp_credentials ->
        {:error, :timeout}
      end
    )
  end

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

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

  def when_verification_succeeds do
    expect(
      EpicFantasyForgeWeb.SupabaseGoTrueAPIMock,
      :verify_otp,
      fn _client, @otp_params ->
        {:ok, @session}
      end
    )
  end

  def when_code_exchange_fails do
    expected_code = TestOAuthAtoms.code()
    # The code verifier is not nil in real life, however I wasn't able to figure
    # out how to mock the session in tests from which the code verifier is
    # taken. Passing a mocked session as a third parameter to the "live"
    # function does not seem to work for unknown reasons.
    expect(
      EpicFantasyForgeWeb.SupabaseGoTrueAPIMock,
      :exchange_code_for_session,
      fn _client, ^expected_code, nil ->
        {:error, :timeout}
      end
    )
  end

  def when_code_exchange_succeeds do
    expected_code = TestOAuthAtoms.code()

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

Append the below at the end of the test_helper.exs file located in the test directory:

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

Mox.defmock(EpicFantasyForgeWeb.SupabaseGoTrueAPIMock,
  for: EpicFantasyForgeWeb.SupabaseGoTrueAPI
)

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

Application.put_env(
  :epic_fantasy_forge,
  :supabase_go_true_api,
  EpicFantasyForgeWeb.SupabaseGoTrueAPIMock
)

App

In order to mock the dependencies we need to create a layer of abstraction between our code and the third-party code. We will create a trait for this. A trait in Rust is similar to an interface in other programming languages.

To create this trait, create a new directory named dependencies at the path src-tauri/src/ in your Tauri project. Then create a file named dependencies.rs in this new directory and populate it with the below content:

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 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<()>;
}

We will now add the Rust tests. In the src-tauri directory, create a new directory named tests. Inside this new directory create a file named deep_link.rs and populate it with the below content:

deep_link.rs
mod utilities;

use epic_fantasy_forge_lib::{
  authentication::{
    deep_link::on_open_url,
    oauth::PKCE_VERIFIER
  },
  dependencies::dependencies::MockDependencies
};
use oauth2::PkceCodeChallenge;
use utilities::{get_session, REFRESH_TOKEN};

#[test]
fn shows_error_when_code_url_query_parameter_missing() {
    let mut mock = MockDependencies::new();
    let url = url::Url::parse(
      "epic-fantasy-forge://app?error=error")
      .unwrap();
    initialize_pkce_verifier();

    let result = on_open_url(&mock, Some(&url));

    mock.expect_exchange_code_for_session().never();
    assert!(result.is_err());
}

#[test]
fn shows_error_when_pkce_verifier_invalid() {
    let mut mock = MockDependencies::new();
    mock
      .expect_exchange_code_for_session()
      .returning(|_, _| Err(supabase_auth::error::Error::InternalError));
    let url = url::Url::parse(
      "epic-fantasy-forge://app?code=azv7hm2n-4m21-1wev-fn2f-p49kw07p6ih7")
      .unwrap();
    *PKCE_VERIFIER.lock().unwrap() = None;

    let result = on_open_url(&mock, Some(&url));

    assert!(result.is_err());
}

#[test]
fn shows_error_when_code_exchange_fails() {
    let mut mock = MockDependencies::new();
    mock
      .expect_exchange_code_for_session()
      .returning(|_, _| Err(supabase_auth::error::Error::InternalError));
    let url = url::Url::parse(
      "epic-fantasy-forge://app?code=azv7hm2n-4m21-1wev-fn2f-p49kw07p6ih7")
      .unwrap();
    initialize_pkce_verifier();

    let result = on_open_url(&mock, Some(&url));

    assert!(result.is_err());
}

#[test]
fn shows_success_when_code_exchange_succeeds() {
    let mut mock = MockDependencies::new();
    mock
      .expect_exchange_code_for_session()
      .returning(|_, _| Ok(get_session()));
    mock.expect_save_refresh_token()
      .withf(|token| token == REFRESH_TOKEN)
      .returning(|_| Ok(()));
    let url = url::Url::parse(
      "epic-fantasy-forge://app?code=azv7hm2n-4m21-1wev-fn2f-p49kw07p6ih7")
      .unwrap();
    initialize_pkce_verifier();

    let result = on_open_url(&mock, Some(&url));

    assert!(result.is_ok());
}

fn initialize_pkce_verifier() {
  *PKCE_VERIFIER.lock().unwrap() = None;
  let (_pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
  PKCE_VERIFIER.lock().unwrap().replace(pkce_verifier);
}

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

oauth.rs
use epic_fantasy_forge_lib::dependencies::dependencies::MockDependencies;
use epic_fantasy_forge_lib::authentication::oauth::login_with_oauth;

const OAUTH_URL : &str = "https://dxupnrgexypxfbhucpgi.supabase.co/auth/v1/authorize?code_challenge=If91BQRQhvZ7S6tEmgbZY00OezEO3Wt6LJVv9uVD1iJ&code_challenge_method=s256&provider=gitlab&redirect_to=epic-fantasy-forge://app";

#[test]
fn shows_error_when_provider_is_invalid() {
    let mock = MockDependencies::new();

    let result = login_with_oauth(&mock, "invalid".to_string());

    assert!(result.is_err());
}

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

    let result = login_with_oauth(&mock, "gitlab".to_string());

    assert!(result.is_err());
}

#[test]
fn shows_error_when_redirect_fails() {
    let mut mock = MockDependencies::new();
    mock
      .expect_login_with_oauth()
      .returning(|_, _| Ok(supabase_auth::models::OAuthResponse {
        url: url::Url::parse(OAUTH_URL).unwrap(),
        provider: supabase_auth::models::Provider::Gitlab,
      }));
    mock
      .expect_open_url()
      .returning(|_| Err(tauri_plugin_opener::Error::UnsupportedPlatform));

    let result = login_with_oauth(&mock, "gitlab".to_string());

    assert!(result.is_err());
}

#[test]
fn redirects_when_oauth_login_initialization_succeeds() {
    let mut mock = MockDependencies::new();
    mock
      .expect_login_with_oauth()
      .returning(|_, _| Ok(supabase_auth::models::OAuthResponse {
        url: url::Url::parse(OAUTH_URL).unwrap(),
        provider: supabase_auth::models::Provider::Gitlab,
      }));
    mock
      .expect_open_url()
      .withf(|url| url == OAUTH_URL)
      .returning(|_| Ok(()));

    let result = login_with_oauth(&mock, "gitlab".to_string());

    assert!(result.is_ok());
}

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

otp.rs
mod utilities;

use epic_fantasy_forge_lib::dependencies::dependencies::MockDependencies;
use epic_fantasy_forge_lib::authentication::otp::{login_with_otp, verify_otp_code, EMAIL};
use supabase_auth::models::OTPResponse;
use utilities::{get_session, REFRESH_TOKEN};

const OTP_CODE : &str = "545719";
const OTP_EMAIL: &str = "test@example.com";

#[test]
fn shows_error_when_otp_login_fails() {
    let mut mock = MockDependencies::new();
    mock
      .expect_login_with_otp()
      .returning(|_| Err(supabase_auth::error::Error::InternalError));
    initialize_email(None);

    let result = login_with_otp(&mock, "".to_string());

    assert!(result.is_err());
    assert!(EMAIL.lock().unwrap().is_none());
}

#[test]
fn shows_success_when_otp_login_succeeds() {
    let mut mock = MockDependencies::new();
    mock
      .expect_login_with_otp()
      .returning(|_| Ok(OTPResponse { message_id: None }));
    initialize_email(Some(OTP_EMAIL.to_string()));

    let result = login_with_otp(&mock, OTP_EMAIL.to_string());

    assert!(result.is_ok());
    assert_eq!(EMAIL.lock().unwrap().as_ref().unwrap(), &OTP_EMAIL.to_string());
}

#[test]
fn shows_error_when_otp_code_verification_fails() {
    let mut mock = MockDependencies::new();
    mock
      .expect_verify_otp_code()
      .returning(|_, _| Err(supabase_auth::error::Error::InternalError));
    initialize_email(Some(OTP_EMAIL.to_string()));

    let result = verify_otp_code(&mock, OTP_CODE.to_string());

    assert!(result.is_err());
}

#[test]
fn shows_error_when_saving_refresh_token_fails() {
    let mut mock = MockDependencies::new();
    mock
      .expect_verify_otp_code()
      .returning(|_, _| Ok(get_session()));
    mock.expect_save_refresh_token()
      .returning(|_| Err(anyhow::anyhow!("")));
    initialize_email(Some(OTP_EMAIL.to_string()));

    let result = verify_otp_code(&mock, OTP_CODE.to_string());

    assert!(result.is_err());
}

#[test]
fn shows_success_when_otp_code_verification_succeeds() {
    let mut mock = MockDependencies::new();
    mock
      .expect_verify_otp_code()
      .withf(|email, code| email == OTP_EMAIL && code == OTP_CODE)
      .returning(|_, _| Ok(get_session()));
    mock.expect_save_refresh_token()
      .withf(|token| token == REFRESH_TOKEN)
      .returning(|_| Ok(()));
    initialize_email(Some(OTP_EMAIL.to_string()));

    let result = verify_otp_code(&mock, OTP_CODE.to_string());

    assert!(result.is_ok());
}

fn initialize_email(email: Option<String>) {
  *EMAIL.lock().unwrap() = email;
}

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

utilities.rs
use std::collections::HashMap;
use supabase_auth::models::{AppMetadata, UserMetadata};
use uuid::Uuid;

pub const REFRESH_TOKEN : &str = "qwvul75mznw7";

pub fn get_session() -> supabase_auth::models::Session {
  supabase_auth::models::Session {
    access_token: "".into(),
    token_type: "".into(),
    expires_in: 0,
    refresh_token: REFRESH_TOKEN.into(),
    user: get_user(),
    provider_token: None,
    provider_refresh_token: None,
    expires_at: 0,
  }
}

fn get_user() -> supabase_auth::models::User {
  supabase_auth::models::User {
    id: Uuid::new_v4(),
    email: "".into(),
    phone: "".into(),
    app_metadata: AppMetadata {
      provider: Some("".into()),
      providers: Some(vec!["".into()]),
    },
    user_metadata: get_user_metadata(),
    created_at: "".into(),
    updated_at: "".into(),
    role: "".into(),
    aud: "".into(),
    invited_at: None,
    confirmation_sent_at: None,
    email_confirmed_at: None,
    phone_confirmed_at: None,
    confirmed_at: None,
    recovery_sent_at: None,
    last_sign_in_at: None,
    identities: vec![],
    is_anonymous: false,
  }
}

fn get_user_metadata() -> UserMetadata {
  UserMetadata {
    full_name: Some("".into()),
    avatar_url: Some("".into()),
    name: None,
    email: None,
    email_verified: None,
    phone_verified: None,
    picture: None,
    custom: HashMap::new(),
  }
}

In the directory src/test, create a file named code-input.test.ts and populate it with the below content:

code-input.test.ts
import '@testing-library/jest-dom';
import { describe, it, beforeEach, expect } from "vitest";
import { initializeCodeInput } from '../ts/code-input';

describe("Code Input", () => {
  const numberOfDigits = 6;

  let inputs: HTMLInputElement[];
  let container: HTMLDivElement;

  beforeEach(() => {
    container = document.createElement("div");
    inputs = [];

    for (let index = 0; index < numberOfDigits; index++) {
      const input = document.createElement("input");
      input.setAttribute("otp-digit", "");

      inputs.push(input);
      container.appendChild(input);
    }

    document.body.appendChild(container);
    initializeCodeInput(container);
  });

  it("works on pages without code input", () => {
    container.innerHTML = ``;

    expect(() => initializeCodeInput(container)).not.toThrow();
  });

  it("resets input field on non-digit input", () => {
    wheneverInputIs(0, "a");

    expect(inputs[0].value).toBe("");
  });

  it("no inputs focused when digit entered into last input box", () => {
    wheneverInputIs(numberOfDigits - 1, "0");

    inputs.forEach(input => {
      expect(input).not.toHaveFocus();
    });
  });

  it("next input field is focused on digit entry", () => {
    wheneverInputIs(0, "0");

    expect(inputs[1]).toHaveFocus();
  });

  it("previous input field is not focused on non-backspace key press in input box", () => {
    wheneverKeyIsPressed(1, "Enter");

    expect(inputs[0]).not.toHaveFocus();
    expect(inputs[1]).toHaveFocus();
  });

  it("previous input field is not focused on backspace key press in first input box", () => {
    wheneverKeyIsPressed(0, "Backspace");

    expect(inputs[0]).toHaveFocus();
  });

  it("previous input field is focused on backspace key press", () => {
    wheneverKeyIsPressed(1, "Backspace");

    expect(inputs[0]).toHaveFocus();
  });

  it("resets input field on focus", () => {
    inputs[0].value = "a";
    inputs[0].focus();

    expect(inputs[0].value).toBe("");
  });

  function wheneverInputIs(index: number, value: string) {
    inputs[index].focus();
    inputs[index].value = value;
    inputs[index].dispatchEvent(new Event("input", { bubbles: true }));
  }

  function wheneverKeyIsPressed(index: number, key: string) {
    inputs[index].focus();
    inputs[index].dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
  }
});

In the directory src/test, create a file named toast.test.ts and populate it with the below content:

toast.test.ts
import { waitFor } from "@testing-library/dom";
import { describe, it, beforeEach, expect } from "vitest";
import { initializeToast } from "../components/toast";


describe("Toast", () => {
  const animationEndKey = "animationend";
  const hiddenClass = "hidden";
  const timeout = 5000;

  const toastId = {
    info: "toast-info",
    error: "toast-error",
  };

  let toastInfo: HTMLElement | null;
  let toastError: HTMLElement | null;

  beforeEach(() => {
    document.body.innerHTML = `
      <div id="${toastId.info}"></div>
      <div id="${toastId.error}"></div>
    `;

    toastInfo = document.getElementById(toastId.info) as HTMLDivElement;
    toastError = document.getElementById(toastId.error) as HTMLDivElement;
  });

  it("works on pages without toast", () => {
    document.body.innerHTML = ``;
    expect(() => initializeToast()).not.toThrow();
  });

  it("shows toast initially", () => {
    initializeToast();
    expect(toastInfo?.classList.contains(hiddenClass)).toBe(false);
    expect(toastError?.classList.contains(hiddenClass)).toBe(false);
  });

  it("hides toast after animation ends", async () => {
    initializeToast();
    toastInfo!.dispatchEvent(new Event(animationEndKey));
    toastError!.dispatchEvent(new Event(animationEndKey));

    await waitFor(() => {
      expect(toastInfo?.classList.contains(hiddenClass)).toBe(true);
      expect(toastError?.classList.contains(hiddenClass)).toBe(true);
    }, { timeout });
  });
});

In your Tauri project's root directory, replace the contents of the file vite.config.js with the below:

vite.config.js
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from '@tailwindcss/vite';

// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [sveltekit(), tailwindcss()],

  // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
  //
  // 1. prevent vite from obscuring rust errors
  clearScreen: false,
  // 2. tauri expects a fixed port, fail if that port is not available
  server: {
    port: 1420,
    strictPort: true,
    host: host || false,
    hmr: host
      ? {
          protocol: "ws",
          host,
          port: 1421,
        }
      : undefined,
    watch: {
      // 3. tell vite to ignore watching `src-tauri`
      ignored: ["**/src-tauri/**"],
    },
  },
  test: {
   environment: 'jsdom',
   coverage: {
    provider: 'v8',
    reporter: ['text', 'html']
   },
   globals: true
  }
});

Production Code

Web

Replace the contents of app.js located in assets/js in your Phoenix project with the below content:

app.js
document.addEventListener('DOMContentLoaded', function() {
  import("../ts/app.ts");
});

Replace the contents of app.ts located in assets/ts in your Phoenix project with the below content:

app.ts
import { initializeNavigationBar } from "./navigation-bar";
import { initializeLiveView } from "./live-view";
import { initializeToast } from "./toast";

initializeNavigationBar();
initializeLiveView();
initializeToast();

Create a new file named code-input.ts in the directory assets/ts in your Phoenix project and populate it with the below content:

code-input.ts
export function initializeCodeInput(container: HTMLElement) {
  new CodeInput(container);
}

class CodeInput {
  private static readonly otpRegex = /^\d{6}$/;
  private static readonly isDigitRegex = /^\d$/;

  private inputs: HTMLInputElement[];

  constructor(container: HTMLElement) {
    this.inputs = Array.from(
      container.querySelectorAll<HTMLInputElement>("input[otp-digit]"));

    this.inputs.forEach((input, index) => {
      input.oninput = () => this.onInput(index);
      input.onkeydown = (event) => this.onKeyDown(index, event);
      input.onfocus = () => { input.value = ""; };
      input.onpaste = (event) => this.onPaste(event);
    });
  }

  private onInput(index: number) {
    const input = this.inputs[index];

    if (CodeInput.isDigitRegex.test(input.value)) {
      if (index + 1 < this.inputs.length) {
        this.inputs[index + 1].focus();
      } else {
        input.blur();
      }
    } else {
      input.value = '';
    }
  }

  private onKeyDown(index: number, keyboardEvent: KeyboardEvent) {
    if (keyboardEvent.key === "Backspace" && index > 0) {
      this.inputs[index - 1].focus();
    }
  }

  // I have not figured out a way how to test this with Jest. When trying to
  // simulate a ClipboardEvent in a Jest test, it gives the below error:
  // ReferenceError: ClipboardEvent is not defined
  private onPaste(clipboardEvent: ClipboardEvent) {
    const pastedText = clipboardEvent.clipboardData?.getData("text") ?? "";

    if (CodeInput.otpRegex.test(pastedText)) {
      clipboardEvent.preventDefault();

      pastedText.split("").forEach((char, index) => {
        if (this.inputs[index]) {
          this.inputs[index].value = char;
          this.inputs[index].blur();
        }
      });
    }
  }
}

Create a new file named live-view.ts in the directory assets/ts in your Phoenix project and populate it with the below content:

live-view.ts
import topbar from "topbar";
import { LiveSocket } from "phoenix_live_view";
import { Socket } from "phoenix";
import type { ViewHookInterface } from "phoenix_live_view";
import { handleOAuthRedirectEvent } from "./o-auth-redirect";
import { initializeCodeInput } from "./code-input";
import { initializeToast } from "./toast";

export function initializeLiveView() {
  const csrfToken =
    document.querySelector("meta[name='csrf-token']")
    ?.getAttribute("content") ||
    "";

  const liveSocket = new LiveSocket("/live", Socket, {
    longPollFallbackMs: 2500,
    params: { _csrf_token: csrfToken },
    hooks: getLiveViewHooks(),
  });

  const BLUE = "#3b82f6";

  topbar.config({ barColors: { 0: BLUE } });
  window.addEventListener("phx:page-loading-start", () => topbar.show(300));
  window.addEventListener("phx:page-loading-stop", () => topbar.hide());

  liveSocket.connect();
  window.liveSocket = liveSocket;
}

function getLiveViewHooks() {
  const hooks: Record<string, object> = {};

  hooks.ClientError = getClientErrorHook();
  hooks.OAuthRedirect = getOAuthRedirectHook();
  hooks.ShowError = getShowErrorHook();
  hooks.VerificationCodeInput = getCodeInputHook();

  return hooks;
}

function getClientErrorHook() {
  return {
    mounted(this: ViewHookInterface) {
      window.addEventListener("client-error", (event: Event) => {
        const customEvent = event as CustomEvent;
        const error = customEvent.detail?.error || "Something went wrong";
        this.pushEvent("client_error", { error });
      });
    }
  };
}

function getOAuthRedirectHook() {
  return {
    mounted(this: ViewHookInterface) {
      this.handleEvent(
        "o-auth-redirect",
        handleOAuthRedirectEvent
      );
    }
  };
}

function getShowErrorHook() {
  return {
    mounted(this: ViewHookInterface) {
      this.handleEvent("show-toast", () => {
        initializeToast();
      });
    }
  };
}

function getCodeInputHook() {
  return {
    mounted(this: ViewHookInterface) {
      initializeCodeInput(this.el);
    }
  };
}

Create a new file named o-auth-redirect.ts in the directory assets/ts in your Phoenix project and populate it with the below content:

o-auth-redirect.ts
export function handleOAuthRedirectEvent(
    { url, code_verifier }: { url: string; code_verifier: string }) {
    return fetch(
        `/api/session?code_verifier=${encodeURIComponent(code_verifier)}`,
        { method: "post" }
    ).then((response) => {
        if (response.ok) {
            window.location.href = url;
        } else {
            generateClientErrorEvent();
        }
    }).catch(() => {
        generateClientErrorEvent();
    });
}

function generateClientErrorEvent() {
    console.error("Failed to save code verifier to session");

    const event = new CustomEvent(
      "client-error",
      { detail: { error: "Login failed" } }
    );

    window.dispatchEvent(event);
}

Create a new file named toast.ts in the directory assets/ts in your Phoenix project and populate it with the below content:

toast.ts
export function initializeToast() {
  const hiddenClass = "hidden";

  const toastIds = ["toast-info", "toast-error"];

  toastIds.forEach(id => {
    const toast = document.getElementById(id) as HTMLElement | null;
    if (!toast) return;

    toast.classList.remove(hiddenClass);
    const onAnimationEnd = () => {
      toast.removeEventListener('animationend', onAnimationEnd);
      toast.classList.add(hiddenClass);
    };
    toast.addEventListener('animationend', onAnimationEnd);
  });
}

Add the below line to application.ex located in the directory lib/epic_fantasy_forge in your Phoenix project. Add the line into the children array.

application.ex
EpicFantasyForge.Supabase.Client

Create a new directory named supabase in the directory lib/epic_fantasy_forge in your Phoenix project. In the new directory create a file named client.ex and populate it with the below content:

client.ex
defmodule EpicFantasyForge.Supabase.Client do
  @moduledoc """
  Supabase self-managed client
  """
  use Supabase.Client, otp_app: :epic_fantasy_forge
end

Create a new directory named api in the directory lib/epic_fantasy_forge_web in your Phoenix project. In the new directory create a file named supabase_client_api.ex and populate it with the below content:

supabase_client_api.ex
defmodule EpicFantasyForgeWeb.SupabaseClientAPI do
  @moduledoc """
  Supabase Client API interface. Allows for mocking in tests.
  """

  @type client :: any()

  @callback get_client() ::
              {:ok, EpicFantasyForge.Supabase.Client} | {:error, any()}

  def get_client do
    impl().get_client()
  end

  defp impl,
    do:
      Application.get_env(
        :epic_fantasy_forge,
        :supabase_client_api,
        Supabase.Client
      )
end

Create a new file named supabase_go_true_api.ex in the directory lib/epic_fantasy_forge_web/api in your Phoenix project and populate it with the below content:

supabase_go_true_api.ex
defmodule EpicFantasyForgeWeb.SupabaseGoTrueAPI do
  @moduledoc """
  Supabase GoTrue 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()}

  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

  defp impl,
    do:
      Application.get_env(
        :epic_fantasy_forge,
        :supabase_go_true_api,
        Supabase.GoTrue
      )
end

Delete the file app_html.ex located in the directory lib/epic_fantasy_forge_web/controllers in your Phoenix project.

Now create a directory named app in the directory lib/epic_fantasy_forge_web/components in your Phoenix project. In this new directory create a file named app_components.ex and populate it with the below content:

app_components.ex
defmodule EpicFantasyForgeWeb.AppComponents do
  @moduledoc """
  App components for the Epic Fantasy Forge app.
  """

  use EpicFantasyForgeWeb, :html

  embed_templates "templates/*"
end

Create a file named modal_structs.ex in the directory lib/epic_fantasy_forge_web/components/app in your Phoenix project and populate it with the below content:

modal_structs.ex
defmodule EpicFantasyForgeWeb.Modal do
  @moduledoc """
  Modal dialog.
  """
  @enforce_keys [:description, :buttons]
  defstruct [:description, :buttons]
end

defmodule EpicFantasyForgeWeb.ModalDescription do
  @moduledoc """
  Contains the text content of a modal dialog.
  """
  @enforce_keys [:title, :text, :bullets]
  defstruct [:title, :text, :bullets]
end

defmodule EpicFantasyForgeWeb.ModalButtons do
  @moduledoc """
  Contains the button labels for a modal dialog.
  """
  @enforce_keys [
    :positive_label,
    :negative_label,
    :positive_event,
    :negative_event
  ]
  defstruct [:positive_label, :negative_label, :positive_event, :negative_event]
end

Create a new directory named templates inside the lib/epic_fantasy_forge_web/components/app directory. Inside this new directory create a new file named loading_spinner.html.heex and populate it with the below content:

loading_spinner.html.heex
<svg
  id="loading-spinner"
  width="24"
  height="24"
  viewBox="0 0 24 24"
  xmlns="http://www.w3.org/2000/svg"
  fill={@color}
>
  <style>
    .spinner_1KD7{animation:spinner_6QnB 1.2s infinite}
    .spinner_MJg4{animation-delay:.1s}
    .spinner_sj9X{animation-delay:.2s}
    .spinner_WwCl{animation-delay:.3s}
    .spinner_vy2J{animation-delay:.4s}
    .spinner_os1F{animation-delay:.5s}
    .spinner_l1Tw{animation-delay:.6s}
    .spinner_WNEg{animation-delay:.7s}
    .spinner_kugV{animation-delay:.8s}
    .spinner_4zOl{animation-delay:.9s}
    .spinner_7he2{animation-delay:1s}
    .spinner_SeO7{animation-delay:1.1s}
    @keyframes spinner_6QnB {
      0%,
      50% {
        animation-timing-function: cubic-bezier(0.27, .42, .37, .99);
        r: 0
      }
      25% {
        animation-timing-function: cubic-bezier(0.53, 0, .61, .73);
        r: 2px
      }
    }
  </style>
  <circle class="spinner_1KD7" cx="12" cy="3" r="0" />
  <circle class="spinner_1KD7 spinner_MJg4" cx="16.50" cy="4.21" r="0" />
  <circle class="spinner_1KD7 spinner_SeO7" cx="7.50" cy="4.21" r="0" />
  <circle class="spinner_1KD7 spinner_sj9X" cx="19.79" cy="7.50" r="0" />
  <circle class="spinner_1KD7 spinner_7he2" cx="4.21" cy="7.50" r="0" />
  <circle class="spinner_1KD7 spinner_WwCl" cx="21.00" cy="12.00" r="0" />
  <circle class="spinner_1KD7 spinner_4zOl" cx="3.00" cy="12.00" r="0" />
  <circle class="spinner_1KD7 spinner_vy2J" cx="19.79" cy="16.50" r="0" />
  <circle class="spinner_1KD7 spinner_kugV" cx="4.21" cy="16.50" r="0" />
  <circle class="spinner_1KD7 spinner_os1F" cx="16.50" cy="19.79" r="0" />
  <circle class="spinner_1KD7 spinner_WNEg" cx="7.50" cy="19.79" r="0" />
  <circle class="spinner_1KD7 spinner_l1Tw" cx="12" cy="21" r="0" />
</svg>

Create a new file named modal.html.heex inside the lib/epic_fantasy_forge_web/components/app/templates directory and populate it with the below content:

modal.html.heex
<div
  id="modal-warning"
  tabindex="0"
  class="
    fixed
    inset-0
    bg-black/70
    backdrop-blur-md
    backdrop-saturate-150
    flex
    items-center
    justify-center
    p-4
    text-center
    z-50
    focus:outline-none
    overflow-y-auto
  "
>
  <div class="
      relative
      transform
      overflow-hidden
      rounded-2xl
      shadow-2xl
      bg-gray-900
      border-2
      border-red-500
      ring-1
      ring-white/20
      p-8
      sm:pt-10
      text-left
      sm:my-8
      sm:w-full
      sm:max-w-lg
      max-h-[90vh]
      animate-modal
    ">
    <div class="
      sm:flex
      sm:items-start
    ">
      <div class="
        mx-auto
        flex
        size-16
        shrink-0
        items-center
        justify-center
        rounded-full
        shadow-lg
        sm:mx-0
        sm:size-16
      ">
        <img
          class="mx-auto h-16 object-contain"
          src="images/icons/warning.svg"
          alt="Warning"
        />
      </div>
      <div class="mt-3 sm:mt-0 sm:ml-6 sm:text-left flex flex-col w-full">
        <h3
          id="dialog-title"
          class="
            text-center
            sm:text-left
            text-lg
            font-bold
            text-white
            tracking-wide
            flex-shrink-0
          "
        >
          {@modal.description.title}
        </h3>
        <div
          class="mt-3 overflow-y-auto flex-1 min-h-0"
          style="max-height: 32vh;"
        >
          <p class="
            text-base
            text-gray-300
            font-medium
          ">
            {@modal.description.text}
          </p>
          <ul class="
            list-disc
            list-outside
            ml-6
            text-gray-300
            text-base
            pt-3
            space-y-1
          ">
            <%= for bullet <- @modal.description.bullets do %>
              <li>{bullet}</li>
            <% end %>
          </ul>
        </div>
      </div>
    </div>
    <div class="
      mt-7
      sm:mt-6
      sm:flex
      sm:flex-row-reverse
      gap-3
    ">
      <button
        phx-click={@modal.buttons.negative_event}
        type="button"
        command="close"
        commandfor="dialog"
        class="
          flex
          w-full
          justify-center
          rounded-md
          bg-re-500
          px-4
          py-1.5
          mb-4
          text-sm/6
          font-semibold
          text-white
          bg-red-500
          hover:bg-red-400
          hover:scale-105
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
          sm:mb-0
          sm:ml-3
          sm:w-auto
        "
      >
        {@modal.buttons.negative_label}
      </button>
      <button
        phx-click={@modal.buttons.positive_event}
        type="button"
        command="close"
        commandfor="dialog"
        class="
          flex
          w-full
          justify-center
          rounded-md
          px-4
          py-1.5
          text-sm/6
          font-semibold
          text-white
          bg-indigo-500
          hover:bg-indigo-400
          shadow-md
          ring-1
          ring-white/10
          hover:scale-105
          transition-all
          focus-visible:ring-2
          focus-visible:ring-indigo-400
          sm:ml-0
          sm:w-auto
        "
      >
        {@modal.buttons.positive_label}
      </button>
    </div>
  </div>
</div>

Create a new file named toast.html.heex in the lib/epic_fantasy_forge_web/components/app/templates directory and populate it with the below content:

toast.html.heex
<%= if Phoenix.Flash.get(@flash, :info) do %>
  <div
    id="toast-info"
    class="
      fixed
      top-6
      left-1/2
      -translate-x-1/2
      bg-black/70
      text-white
      px-6
      py-3
      gap-3
      z-50
      rounded-xl
      shadow-2xl
      flex
      items-center
      justify-center
      min-w-[240px]
      animate-toast
      border-2
      border-green-500
      backdrop-blur-md
      backdrop-saturate-150
      ring-1 ring-white/20
      "
    style="box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);"
  >
    <svg
      class="h-8 w-8 text-green-400 drop-shadow"
      fill="none"
      viewBox="0 0 24 24"
      stroke="currentColor"
    >
      <path
        stroke-linecap="round"
        stroke-linejoin="round"
        stroke-width="2"
        d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
      />
    </svg>
    <span class="font-semibold tracking-wide">
      {Phoenix.Flash.get(@flash, :info)}
    </span>
  </div>
<% end %>

<%= if Phoenix.Flash.get(@flash, :error) do %>
  <div
    id="toast-error"
    class="
      fixed
      top-6
      left-1/2
      -translate-x-1/2
      bg-black/70
      text-white
      px-6
      py-3
      gap-3
      z-50
      rounded-xl
      shadow-2xl
      flex
      items-center
      justify-center
      min-w-[240px]
      animate-toast
      border-2
      border-red-500
      backdrop-blur-md
      backdrop-saturate-150
      ring-1 ring-white/20
      "
    style="box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);"
  >
    <svg
      class="h-8 w-8 text-red-400 drop-shadow"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    >
      <circle cx="12" cy="12" r="10" />
      <line x1="15" y1="9" x2="9" y2="15" />
      <line x1="9" y1="9" x2="15" y2="15" />
    </svg>
    <span class="font-semibold tracking-wide">
      {Phoenix.Flash.get(@flash, :error)}
    </span>
  </div>
<% end %>

Replace the contents of the file app.html.heex located in the lib/epic_fantasy_forge_web/components/layouts directory with the below content:

app.html.heex
<main class="
    min-h-screen
    bg-gradient-to-br
    from-indigo-950
    via-gray-900
    to-fuchsia-950">
  <div id="app-live-root" phx-hook="OAuthRedirect">
    <EpicFantasyForgeWeb.AppComponents.toast flash={@flash} />
    {@inner_content}
  </div>
</main>

In the file navigation_bar.html.heex located in the lib/epic_fantasy_forge_web/components/web/templates directory replace the div that contains the Forge World button with the below:

navigation_bar.html.heex
<div class="flex items-center hover:scale-105 transition-all">
  <div class="shrink-0">
    <a
      href="/app"
      class="
        rounded-md
        bg-indigo-500
        px-3.5
        py-2.5
        text-sm
        font-semibold
        text-white
        shadow-xs
        hover:bg-indigo-400
        focus-visible:outline-2
        focus-visible:outline-offset-2
        focus-visible:outline-indigo-400"
    >
      Forge World
    </a>
  </div>
</div>

Delete the file app_controller.ex located in the directory lib/epic_fantasy_forge_web/controllers.

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

session_controller.ex
defmodule EpicFantasyForgeWeb.SessionController do
  use EpicFantasyForgeWeb, :controller

  @allowed_keys [:code_verifier]

  def set(conn, params) do
    case Enum.find(@allowed_keys, fn key ->
           Map.has_key?(params, Atom.to_string(key))
         end) do
      nil ->
        send_resp(
          conn,
          400,
          "No valid key provided. The allowed keys are: #{@allowed_keys |> Enum.join(", ")}"
        )

      key ->
        conn
        |> put_session(key, params[Atom.to_string(key)])
        |> send_resp(204, "")
    end
  end

  def allowed_keys, do: @allowed_keys
end

Replace the contents of endpoint.ex located in lib/epic_fantasy_forge_web with the below:

endpoint.ex
defmodule EpicFantasyForgeWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :epic_fantasy_forge

  if sandbox = Application.compile_env(:epic_fantasy_forge, :sandbox, false) do
    plug Phoenix.Ecto.SQL.Sandbox, sandbox: sandbox
  end

  @session_key (case Mix.env() do
                  :dev -> "_epic_fantasy_forge_dev_key"
                  _ -> "_epic_fantasy_forge_key"
                end)

  @session_options [
    store: :cookie,
    key: @session_key,
    signing_salt: "KOMJTZ0q",
    encryption_salt: "WsSUk0TxN",
    same_site: "Lax",
    # 1 year
    max_age: 60 * 60 * 24 * 365
  ]

  @gzip (case Mix.env() do
           :prod -> true
           _ -> false
         end)

  socket "/live", Phoenix.LiveView.Socket,
    websocket: [connect_info: [session: @session_options]],
    longpoll: [connect_info: [session: @session_options]]

  plug Plug.Static,
    at: "/",
    from: :epic_fantasy_forge,
    gzip: @gzip,
    only: EpicFantasyForgeWeb.static_paths()

  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :epic_fantasy_forge
  end

  plug Phoenix.LiveDashboard.RequestLogger,
    param_key: "request_logger",
    cookie_key: "request_logger"

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug EpicFantasyForgeWeb.Router
end

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

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

  alias EpicFantasyForgeWeb.Authentication
  alias EpicFantasyForgeWeb.OAuth
  alias EpicFantasyForgeWeb.OTP
  alias EpicFantasyForgeWeb.PKCE

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

  @modal_positive_event Authentication.modal_positive_event()
  @modal_negative_event Authentication.modal_negative_event()

  @impl true
  def mount(_params, session, socket) do
    {:ok,
     assign(socket,
       email: nil,
       is_verifying: false,
       loading: nil,
       modal: Authentication.get_modal(),
       otp_code: %{},
       session_data: session,
       should_show_modal: false
     )}
  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_positive_event, _value, socket) do
    socket =
      socket
      |> assign(should_show_modal: false)

    {:noreply, socket}
  end

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

    {:noreply, socket}
  end

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

    {: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({:verify_otp_code, params}, socket) do
    OTP.verify_otp_code(socket, params)
  end

  @impl true
  def handle_params(params, _uri, 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, assign(socket, session: nil)}
    end
  end
end

In the directory lib/epic_fantasy_forge_web/live create a new file named app_live.html.heex and populate it with the below content:

app_live.html.heex
<%= if @should_show_modal do %>
  <EpicFantasyForgeWeb.AppComponents.modal modal={@modal} />
<% end %>

<div
  class="min-h-screen flex flex-col justify-center px-6 py-12 lg:px-8"
  id="container"
  phx-hook="ClientError"
>
  <div
    class="
      sm:mx-auto
      sm:w-full
      sm:max-w-sm
      lg:max-w-4xl
      lg:w-4/5
      lg:flex
      lg:items-center
      lg:justify-center"
    id="sub-container"
    phx-hook="ShowError"
  >
    <div class="
        hidden
        lg:flex
        lg:flex-col
        lg:justify-center
        lg:items-center
        lg:w-1/2
        lg:pr-12">
      <img
        class="mx-auto h-32 w-auto"
        src="/images/logo.png"
        alt="Epic Fantasy Forge"
      />
      <h2 class="mt-8 text-4xl font-extrabold text-white text-center ">
        Account Benefits
      </h2>
      <dl class="
          mt-10
          max-w-xl
          space-y-8
          text-base/7
          text-gray-300
          lg:max-w-none">
        <div class="relative pl-9">
          <dt class="inline font-semibold text-white">
            <svg
              class="absolute top-1 left-0 size-5 text-indigo-500"
              viewBox="0 0 20 20"
              fill="currentColor"
              aria-hidden="true"
              data-slot="icon"
            >
              <path
                fill-rule="evenodd"
                d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
                clip-rule="evenodd"
              />
            </svg>
            Cloud storage
          </dt>
        </div>
        <div class="relative pl-9">
          <dt class="inline font-semibold text-white">
            <svg
              class="absolute top-1 left-0 size-5 text-indigo-500"
              viewBox="0 0 20 20"
              fill="currentColor"
              aria-hidden="true"
              data-slot="icon"
            >
              <path
                fill-rule="evenodd"
                d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
                clip-rule="evenodd"
              />
            </svg>
            Sync worlds across devices
          </dt>
        </div>
        <div class="relative pl-9">
          <dt class="inline font-semibold text-white">
            <svg
              class="absolute top-1 left-0 size-5 text-indigo-500"
              viewBox="0 0 20 20"
              fill="currentColor"
              aria-hidden="true"
              data-slot="icon"
            >
              <path
                fill-rule="evenodd"
                d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
                clip-rule="evenodd"
              />
            </svg>
            Share your worlds with others
          </dt>
        </div>
      </dl>
    </div>
    <div class="w-full lg:w-1/2">
      <div class="sm:mx-auto sm:w-full sm:max-w-sm relative">
        <div class="
            lg:bg-gray-900
            lg:rounded-2xl
            lg:shadow-2xl
            lg:backdrop-blur-xl
            lg:border
            lg:border-white/10
            lg:p-10
            lg:relative
            lg:overflow-hidden">
          <img
            class="mx-auto h-24 mb-6 w-auto lg:hidden"
            src="/images/logo.png"
            alt="Epic Fantasy Forge"
          />
          <h2 class="
              text-center
              text-2xl/9
              font-bold
              tracking-tight
              text-white
              mb-2">
            Log in with:
          </h2>
          <div class="space-y-8">
            <div>
              <div class="mt-6 grid grid-cols-2 gap-4">
                <button
                  phx-click="login_with_oauth"
                  phx-value-provider="gitlab"
                  class="
                    flex
                    w-full
                    items-center
                    justify-center
                    gap-2
                    rounded-md
                    bg-white/90
                    px-3
                    py-2
                    text-sm
                    font-semibold
                    text-gray-900
                    shadow-md
                    ring-1
                    ring-gray-300
                    ring-inset
                    hover:bg-gray-50
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2 
                    focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <img
                    class="mr-3 size-5"
                    src="/images/logos/gitlab.png"
                    alt="Sign in with GitLab"
                  />
                  <span class="text-sm/6 font-semibold">GitLab</span>
                  <span style="display: inline-block; width: 24px; height: 24px;">
                    <%= if @loading == "oauth_gitlab" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="black" />
                    <% end %>
                  </span>
                </button>
                <button
                  phx-click="login_with_oauth"
                  phx-value-provider="github"
                  class="
                    flex
                    w-full
                    items-center
                    justify-center
                    gap-2
                    rounded-md
                    bg-white/90
                    px-3
                    py-2
                    text-sm
                    font-semibold
                    text-gray-900
                    shadow-md
                    ring-1
                    ring-gray-300
                    ring-inset
                    hover:bg-gray-50
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2 focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <svg
                    class="mr-3 size-5 fill-[#24292F]"
                    fill="currentColor"
                    viewBox="0 0 20 20"
                    aria-hidden="true"
                  >
                    <path
                      fill-rule="evenodd"
                      d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
                      clip-rule="evenodd"
                    />
                  </svg>
                  <span class="text-sm/6 font-semibold">GitHub</span>
                  <span style="display: inline-block; width: 24px; height: 24px;">
                    <%= if @loading == "oauth_github" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="black" />
                    <% end %>
                  </span>
                </button>
                <button
                  phx-click="login_with_oauth"
                  phx-value-provider="google"
                  class="
                    flex
                    w-full
                    items-center
                    justify-center
                    gap-2
                    rounded-md
                    bg-white/90
                    px-3
                    py-2
                    text-sm
                    font-semibold
                    text-gray-900
                    shadow-md
                    ring-1
                    ring-gray-300
                    ring-inset
                    hover:bg-gray-50
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2 focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <svg
                    class="mr-3 h-5 w-5"
                    viewBox="0 0 24 24"
                    aria-hidden="true"
                  >
                    <path
                      d="M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z"
                      fill="#EA4335"
                    />
                    <path
                      d="M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z"
                      fill="#4285F4"
                    />
                    <path
                      d="M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z"
                      fill="#FBBC05"
                    />
                    <path
                      d="M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z"
                      fill="#34A853"
                    />
                  </svg>
                  <span class="text-sm/6 font-semibold">Google</span>
                  <span style="display: inline-block; width: 24px; height: 24px;">
                    <%= if @loading == "oauth_google" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="black" />
                    <% end %>
                  </span>
                </button>
                <button
                  phx-click="login_with_oauth"
                  phx-value-provider="apple"
                  class="
                    flex
                    w-full
                    items-center
                    justify-center
                    gap-2
                    rounded-md
                    bg-white/90
                    px-3
                    py-2
                    text-sm
                    font-semibold
                    text-gray-900
                    shadow-md
                    ring-1
                    ring-gray-300
                    ring-inset
                    hover:bg-gray-50
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2 focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <img
                    class="mr-3 size-5"
                    src="/images/logos/apple.png"
                    alt="Log in with Apple"
                  />
                  <span class="text-sm/6 font-semibold">Apple</span>
                  <span style="display: inline-block; width: 24px; height: 24px;">
                    <%= if @loading == "oauth_apple" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="black" />
                    <% end %>
                  </span>
                </button>
                <button
                  phx-click="login_with_oauth"
                  phx-value-provider="azure"
                  class="
                    flex
                    w-full
                    items-center
                    justify-center
                    gap-2
                    rounded-md
                    bg-white/90
                    px-3
                    py-2
                    text-sm
                    font-semibold
                    text-gray-900
                    shadow-md
                    ring-1
                    ring-gray-300
                    ring-inset
                    hover:bg-gray-50
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2 focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <img
                    class="mr-3 size-5"
                    src="/images/logos/microsoft.svg"
                    alt="Log in with Microsoft"
                  />
                  <span class="text-sm/6 font-semibold">Microsoft</span>
                  <span style="display: inline-block; width: 24px; height: 24px;">
                    <%= if @loading == "oauth_azure" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="black" />
                    <% end %>
                  </span>
                </button>
                <button
                  phx-click="login_with_oauth"
                  phx-value-provider="discord"
                  class="
                    flex
                    w-full
                    items-center
                    justify-center
                    gap-2
                    rounded-md
                    bg-white/90
                    px-3
                    py-2
                    text-sm
                    font-semibold
                    text-gray-900
                    shadow-md
                    ring-1
                    ring-gray-300
                    ring-inset
                    hover:bg-gray-50
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2 focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <img
                    class="mr-3 size-5"
                    src="/images/logos/discord.svg"
                    alt="Log in with Discord"
                  />
                  <span class="text-sm/6 font-semibold">Discord</span>
                  <span style="display: inline-block; width: 24px; height: 24px;">
                    <%= if @loading == "oauth_discord" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="black" />
                    <% end %>
                  </span>
                </button>
              </div>
            </div>
            <div class="flex items-center my-4">
              <div class="flex-grow border-t border-gray-400/40"></div>
              <span class="mx-4 text-gray-300 text-sm">or</span>
              <div class="flex-grow border-t border-gray-400/40"></div>
            </div>
            <form
              class="space-y-4"
              phx-submit={
                (@is_verifying && "verify_otp_code") || "login_with_otp"
              }
            >
              <div>
                <label
                  for="email"
                  class="block text-sm/6 font-medium text-white"
                >
                  <%= if @is_verifying do %>
                    Enter the 6 digit code
                  <% else %>
                    Log in or create account with email
                  <% end %>
                </label>
                <div class="mt-2">
                  <%= if @is_verifying do %>
                    <div
                      class="flex gap-2 justify-center w-full mx-auto"
                      id="otp-inputs"
                      phx-hook="VerificationCodeInput"
                    >
                      <%= for i <- 0..5 do %>
                        <input
                          type="text"
                          inputmode="numeric"
                          pattern="[0-9]*"
                          maxlength="1"
                          name={"code[#{i}]"}
                          id={"code_#{i}"}
                          otp-digit
                          class="
                            flex-1
                            min-w-0
                            h-12
                            text-center
                            rounded-md
                            bg-white/10
                            text-white
                            text-2xl
                            outline-1
                            outline-white/10
                            placeholder:text-gray-300
                            focus:outline-2
                            focus:outline-indigo-500
                            transition-all"
                          autocomplete="one-time-code"
                          required
                          value={@otp_code[to_string(i)]}
                        />
                      <% end %>
                    </div>
                  <% else %>
                    <input
                      type="email"
                      name="email"
                      id="email"
                      autocomplete="email"
                      required
                      class="
                        block
                        w-full
                        rounded-md
                        bg-white/10
                        px-3
                        py-1.5
                        text-base
                        text-white
                        outline-1
                        -outline-offset-1 
                        outline-white/10
                        placeholder:text-gray-300
                        focus:outline-2
                        focus:-outline-offset-2
                        focus:outline-indigo-500 sm:text-sm/6"
                      placeholder="user@example.com"
                      value={@email}
                    />
                  <% end %>
                </div>
              </div>
              <div>
                <button
                  type="submit"
                  class="
                    relative
                    flex
                    w-full
                    mx-auto
                    justify-center
                    items-center
                    rounded-md
                    bg-indigo-500
                    px-3
                    py-1.5
                    text-sm/6
                    font-semibold
                    text-white
                    shadow-md
                    hover:bg-indigo-400
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2
                    focus-visible:ring-indigo-400"
                  disabled={@loading != nil}
                >
                  <%= if @is_verifying do %>
                    Verify code
                  <% else %>
                    Send login code
                  <% end %>
                  <span
                    class="absolute right-4 flex items-center"
                    style="width: 24px; height: 24px;"
                  >
                    <%= if @loading == "otp" do %>
                      <EpicFantasyForgeWeb.AppComponents.loading_spinner color="white" />
                    <% end %>
                  </span>
                </button>
              </div>
            </form>
            <div class="flex items-center my-4">
              <div class="flex-grow border-t border-gray-400/40"></div>
              <span class="mx-4 text-gray-300 text-sm">or</span>
              <div class="flex-grow border-t border-gray-400/40"></div>
            </div>
            <div class="flex justify-center">
              <form class="w-full" phx-submit="continue_without_account">
                <button
                  type="submit"
                  class="
                    flex
                    w-full
                    justify-center
                    rounded-md
                    bg-indigo-500
                    px-4
                    py-1.5
                    text-sm/6
                    font-semibold
                    text-white
                    shadow-md
                    hover:bg-indigo-400
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2
                    focus-visible:ring-indigo-400"
                >
                  Continue without account
                </button>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

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

authentication.ex
defmodule EpicFantasyForgeWeb.Authentication do
  @moduledoc """
  Authentication utility module
  """

  import Phoenix.LiveView
  import Phoenix.Component

  alias EpicFantasyForgeWeb.ModalButtons
  alias EpicFantasyForgeWeb.ModalDescription

  require Logger

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

  @modal_positive_event "use_account"
  @modal_negative_event "confirm_without_account"

  @path "/app"

  def modal_positive_event, do: @modal_positive_event
  def modal_negative_event, do: @modal_negative_event

  @dialyzer {:no_match, exchange_code_for_session: 2}
  def exchange_code_for_session(socket, pkce) do
    case supabase_client().get_client() do
      {:ok, client} ->
        case supabase_go_true().exchange_code_for_session(
               client,
               pkce.code,
               pkce.code_verifier
             ) do
          {:ok, session} ->
            {:noreply,
             socket
             |> assign(session: session)
             |> put_flash(:info, @success_message)
             |> push_patch(to: @path)}

          {: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 supabase_client do
    Application.get_env(:epic_fantasy_forge, :supabase_client_api)
  end

  def supabase_go_true do
    Application.get_env(:epic_fantasy_forge, :supabase_go_true_api)
  end

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

  def get_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: @modal_positive_event,
        negative_event: @modal_negative_event
      }
    }
  end
end

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

oauth.ex
defmodule EpicFantasyForgeWeb.OAuth do
  @moduledoc false

  import Phoenix.LiveView
  import Phoenix.Component

  require Logger

  alias EpicFantasyForgeWeb.Authentication

  @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 Authentication.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 Authentication.supabase_go_true().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 directory lib/epic_fantasy_forge_web/live/utilities create a new file named otp.ex and populate it with the below content:

otp.ex
defmodule EpicFantasyForgeWeb.OTP do
  @moduledoc false

  import Phoenix.LiveView
  import Phoenix.Component

  require Logger

  alias EpicFantasyForgeWeb.Authentication

  @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 Authentication.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 Authentication.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 Authentication.supabase_go_true().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 Authentication.supabase_go_true().verify_otp(
           authentication_details.client,
           authentication_details.details
         ) do
      {:ok, session} ->
        socket =
          socket
          |> assign(loading: nil, is_verifying: false, session: session)
          |> put_flash(:info, @verification_success_msg)
          |> push_event("show-toast", %{})

        {:noreply, socket}

      {: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
end

Replace the contents of router.ex located in the directory lib/epic_fantasy_forge_web with the below content:

router.ex
defmodule EpicFantasyForgeWeb.Router do
  use EpicFantasyForgeWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {EpicFantasyForgeWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug :fetch_session
  end

  scope "/", EpicFantasyForgeWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/about", PageController, :about
    get "/acceptable-use-policy", PageController, :acceptable_use_policy
    get "/contact", PageController, :contact
    get "/cookie-policy", PageController, :cookie_policy
    get "/download", PageController, :download
    get "/privacy-policy", PageController, :privacy_policy
    get "/terms-of-service", PageController, :terms_of_service
    get "/third-party", PageController, :third_party

    live "/app", AppLive
  end

  scope "/api", EpicFantasyForgeWeb do
    pipe_through :api

    post "/session", SessionController, :set
  end

  if Application.compile_env(:epic_fantasy_forge, :dev_routes) do
    import Phoenix.LiveDashboard.Router

    scope "/dev" do
      pipe_through :browser

      live_dashboard "/dashboard", metrics: EpicFantasyForgeWeb.Telemetry
      forward "/mailbox", Plug.Swoosh.MailboxPreview
    end
  end
end

In the directory lib/epic_fantasy_forge_web create a new directory named structs. Inside this new directory create a new file named authentication.ex and populate it with the below content:

authentication.ex
defmodule EpicFantasyForgeWeb.AuthenticationDetails do
  @moduledoc """
  Struct to hold authentication details for login
  """
  defstruct client: nil, details: nil
end

defmodule EpicFantasyForgeWeb.PKCE do
  @moduledoc """
  Struct to hold PKCE (Proof Key for Code Exchange) details for OAuth login
  """
  defstruct code: nil, code_verifier: nil
end

App

Create a new directory named authentication in src-tauri/src/ in your Tauri project. Inside this new directory create a file named deep_link.rs and populate it with the below content:

deep_link.rs
use crate::authentication::oauth::PKCE_VERIFIER;
use crate::dependencies::dependencies::Dependencies;
use crate::dependencies::production_dependencies::ProductionDependencies;
use crate::utilities::default_error;

use anyhow::Result;
use oauth2::PkceCodeVerifier;
use tauri::{AppHandle, Emitter};
use tauri_plugin_deep_link::OpenUrlEvent;
use url::Url;

pub fn tauri_on_open_url(app: AppHandle, event: OpenUrlEvent) {
  std::thread::spawn(move || {
    let dependencies = ProductionDependencies { app: app.clone() };
    let url: Option<Url> = event.urls().first().cloned();

    match on_open_url(&dependencies, url.as_ref()) {
      Ok(_) => app.emit("Success", "Logged in").unwrap(),
      Err(_error) => {
        app.emit("Error", "Login failed").unwrap();
      }
    }
  });
}

pub fn on_open_url(dependencies: &dyn Dependencies, url: Option<&Url>) -> Result<()> {
  let (auth_code, pkce_verifier) = get_oauth_info(url)?;
  let session = dependencies.exchange_code_for_session(
    &auth_code,
    pkce_verifier.secret()
  )?;

  dependencies.save_refresh_token(&session.refresh_token)?;

  Ok(())
}

fn get_oauth_info(url: Option<&Url>) -> Result<(String, PkceCodeVerifier)> {
  let parsed_url = url
    .and_then(|u| url::Url::parse(u.as_str()).ok())
    .ok_or(default_error())?;

  let auth_code = parsed_url
    .query_pairs()
    .find_map(|(key, value)| {
      if key == "code" { Some(value.into_owned()) } else { None }
    }).ok_or(default_error())?;

  let pkce_verifier = PKCE_VERIFIER
    .lock()
    .unwrap()
    .take()
    .ok_or(default_error())?;

  Ok((auth_code, pkce_verifier))
}

Now create a file named oauth.rs also located in src-tauri/src/authentication/ and populate it with the below content:

oauth.rs
use std::{collections::HashMap, sync::Mutex};

use oauth2::{PkceCodeChallenge, PkceCodeVerifier};
use supabase_auth::models::{LoginWithOAuthOptions, Provider};
use tauri::Emitter;

use crate::dependencies::dependencies::Dependencies;
use crate::dependencies::production_dependencies::ProductionDependencies;

pub static PKCE_VERIFIER: Mutex<Option<PkceCodeVerifier>> = Mutex::new(None);

#[tauri::command]
pub async fn tauri_login_with_oauth(app: tauri::AppHandle, provider: String) {
  let dependencies = ProductionDependencies { app: app.clone() };
  match login_with_oauth(&dependencies, provider) {
    Ok(_) => app.emit("Success", "Opened web browser").unwrap(),
    Err(_error) => {
      app.emit("Error", "Login failed").unwrap();
    }
  }
}

pub fn login_with_oauth(dependencies: &dyn Dependencies, provider: String) -> Result<(), String> {
  let provider_enum = get_provider(&provider)?;
  let pkce_challenge = initialize_pkce();
  let query_params: HashMap<String, String> = get_query_parameters(&pkce_challenge);

  let options = LoginWithOAuthOptions {
    query_params: Some(query_params),
    redirect_to: Some("epic-fantasy-forge://app".to_string()),
    scopes: Some("".to_string()),
    skip_browser_redirect: Some(false),
  };

  let oauth_response = dependencies
    .login_with_oauth(provider_enum, Some(options))
    .map_err(|_error| "")?;

  dependencies.open_url(oauth_response.url.to_string())
    .map_err(|_error| "")?;

  Ok(())
}

fn get_provider(provider: &str) -> Result<Provider, String> {
  match provider {
    "gitlab" => Ok(Provider::Gitlab),
    "github" => Ok(Provider::Github),
    "google" => Ok(Provider::Google),
    "apple" => Ok(Provider::Apple),
    "azure" => Ok(Provider::Azure),
    "discord" => Ok(Provider::Discord),
    _ => Err("".to_string()),
  }
}

fn initialize_pkce() -> PkceCodeChallenge {
  let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
  PKCE_VERIFIER.lock().unwrap().replace(pkce_verifier);
  pkce_challenge
}

fn get_query_parameters(pkce_challenge: &PkceCodeChallenge) -> HashMap<String, String> {
  let mut query_params = HashMap::new();
  query_params.insert("response_type".to_string(), "code".to_string());
  query_params.insert(
    "code_challenge".to_string(),
    pkce_challenge.as_str().to_owned(),
  );
  query_params.insert("code_challenge_method".to_string(), "S256".to_string());
  query_params
}

Create a file named otp.rs located in src-tauri/src/authentication/ and populate it with the below content:

otp.rs
use crate::dependencies::dependencies::Dependencies;
use crate::dependencies::production_dependencies::ProductionDependencies;
use crate::utilities::default_error;

use anyhow::Result;
use std::sync::Mutex;
use tauri::Emitter;

pub static EMAIL: Mutex<Option<String>> = Mutex::new(None);

#[tauri::command]
pub async fn tauri_login_with_otp(app: tauri::AppHandle, email: String) {
  std::thread::spawn(move || {
    let dependencies = ProductionDependencies { app: app.clone() };

    match login_with_otp(&dependencies, email) {
      Ok(_) => app.emit("Success", "Code sent").unwrap(),
      Err(_error) => {
        app.emit("Error", "Code sending failed").unwrap();
      }
    }
  });
}

#[tauri::command]
pub async fn tauri_verify_otp_code(app: tauri::AppHandle, code: String) {
  std::thread::spawn(move || {
    let dependencies = ProductionDependencies { app: app.clone() };

    match verify_otp_code(&dependencies, code) {
      Ok(_) => app.emit("Success", "Logged in").unwrap(),
      Err(_error) => app.emit("Error", "Code verification failed").unwrap()
    }
  });
}

pub fn login_with_otp(dependencies: &dyn Dependencies, email: String) -> Result<()> {
  dependencies.login_with_otp(&email)?;
  EMAIL.lock().unwrap().replace(email);
  Ok(())
}

pub fn verify_otp_code(dependencies: &dyn Dependencies, code: String) ->
  Result<()> {
  let email = EMAIL.lock().unwrap().clone().ok_or(default_error())?;
  let session = dependencies.verify_otp_code(email, code)?;

  dependencies.save_refresh_token(&session.refresh_token)?;

  Ok(())
}

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

production_dependencies.rs
use crate::dependencies::dependencies::Dependencies;
use anyhow::Result;
use blake3;
use rand::Rng;
use std::{fs, time::Duration};
use supabase_auth::models::{
  AuthClient,
  LoginWithOAuthOptions,
  OAuthResponse,
  OTPResponse,
  OtpType,
  Provider,
  Session,
  VerifyEmailOtpParams,
  VerifyOtpParams
};
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_stronghold::stronghold::Stronghold;

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

impl ProductionDependencies {
  fn generate_key(&self, 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(&self) -> Result<String> {
    Ok(self.app
      .path()
      .app_local_data_dir()?
      .join("epic_fantasy_forge.vault")
      .to_str()
      .ok_or(anyhow::anyhow!(""))?
      .to_string())
  }

  fn intialize_salt(&self) -> Result<Vec<u8>> {
    let salt_file = self.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)?)
    }
  }

  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 open_url(&self, url: String) -> Result<(), tauri_plugin_opener::Error> {
    self.app.opener().open_url(url, None::<&str>)
  }

  // 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.
  fn save_refresh_token(&self, refresh_token: &str) -> Result<()> {
    let salt = self.intialize_salt()?;
    let key = self.generate_key(&salt);

    let stronghold = Stronghold::new(self.get_vault_path()?, 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 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))
    )
  }
}

Replace the content of the file lib.rs located in src-tauri/src/ with the below:

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

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 utilities;

use authentication::deep_link::tauri_on_open_url;
use authentication::oauth::tauri_login_with_oauth;
use authentication::otp::tauri_login_with_otp;
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_opener::init())
    .invoke_handler(tauri::generate_handler![
        tauri_login_with_oauth,
        tauri_login_with_otp,
        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(())
}

Create a new file named utilities.rs in the directory src-tauri/src/ and populate it with the below content:

utilities.rs
pub fn default_error() -> anyhow::Error {
  anyhow::Error::msg("")
}

Replace the content of app.css located in the directory src/ in your Tauri project with the below content:

app.css
@import "tailwindcss";

body {
  font-family: sans-serif;
}

@keyframes modal {
  0% {
    opacity: 0;
    transform: translateX(0) translateY(-60%) scale(0.98);
  }
  60% {
    opacity: 1;
    transform: translateX(0) translateY(8px) scale(1.01);
  }
  80% {
    transform: translateX(0) translateY(0) scale(1.01);
  }
  100% {
    opacity: 1;
    transform: translateX(0) translateY(0) scale(1);
  }
}

@keyframes toast {
  0% {
    opacity: 0;
    transform: translateX(0) translateY(-60%) scale(0.98);
  }
  6% {
    opacity: 1;
    transform: translateX(0) translateY(8px) scale(1.01);
  }
  8% {
    transform: translateX(0) translateY(0) scale(1.01);
  }
  10% {
    opacity: 1;
    transform: translateX(0) translateY(0) scale(1);
  }
  90% {
    opacity: 1;
    transform: translateX(0) translateY(0) scale(1);
  }
  100% {
    opacity: 0;
    transform: translateX(0) translateY(-60%) scale(0.98);
  }
}

.animate-modal {
  animation: modal 0.5s cubic-bezier(0.4,0,0.2,1);
}

.animate-toast {
  animation: toast 5s cubic-bezier(0.4,0,0.2,1);
}

In the src directory of your Tauri project, create a new directory named components. Inside this new directory create a file named loading-spinner.svelte and populate it with the below content:

loading-spinner.svelte
<svg
  width="24"
  height="24"
  viewBox="0 0 24 24"
  fill="currentColor"
>
  <style>
    .spinner_1KD7{animation:spinner_6QnB 1.2s infinite}
    .spinner_MJg4{animation-delay:.1s}
    .spinner_sj9X{animation-delay:.2s}
    .spinner_WwCl{animation-delay:.3s}
    .spinner_vy2J{animation-delay:.4s}
    .spinner_os1F{animation-delay:.5s}
    .spinner_l1Tw{animation-delay:.6s}
    .spinner_WNEg{animation-delay:.7s}
    .spinner_kugV{animation-delay:.8s}
    .spinner_4zOl{animation-delay:.9s}
    .spinner_7he2{animation-delay:1s}
    .spinner_SeO7{animation-delay:1.1s}
    @keyframes spinner_6QnB {
      0%,
      50% {
        animation-timing-function: cubic-bezier(0.27, .42, .37, .99);
        r: 0
      }
      25% {
        animation-timing-function: cubic-bezier(0.53, 0, .61, .73);
        r: 2px
      }
    }
  </style>
  <circle class="spinner_1KD7" cx="12" cy="3" r="0" />
  <circle class="spinner_1KD7 spinner_MJg4" cx="16.50" cy="4.21" r="0" />
  <circle class="spinner_1KD7 spinner_SeO7" cx="7.50" cy="4.21" r="0" />
  <circle class="spinner_1KD7 spinner_sj9X" cx="19.79" cy="7.50" r="0" />
  <circle class="spinner_1KD7 spinner_7he2" cx="4.21" cy="7.50" r="0" />
  <circle class="spinner_1KD7 spinner_WwCl" cx="21.00" cy="12.00" r="0" />
  <circle class="spinner_1KD7 spinner_4zOl" cx="3.00" cy="12.00" r="0" />
  <circle class="spinner_1KD7 spinner_vy2J" cx="19.79" cy="16.50" r="0" />
  <circle class="spinner_1KD7 spinner_kugV" cx="4.21" cy="16.50" r="0" />
  <circle class="spinner_1KD7 spinner_os1F" cx="16.50" cy="19.79" r="0" />
  <circle class="spinner_1KD7 spinner_WNEg" cx="7.50" cy="19.79" r="0" />
  <circle class="spinner_1KD7 spinner_l1Tw" cx="12" cy="21" r="0" />
</svg>

Create a new file named loading-spinner.ts inside the directory src/components in your Tauri project and populate it with the below content:

loading-spinner.ts
import { writable } from 'svelte/store';

export const loading = writable<string | null>(null);

Create a new file named modal.svelte inside the directory src/components in your Tauri project and populate it with the below content:

modal.svelte
<script lang="ts">
  import { modal } from '../components/modal';

  let shouldDisplayModal = false;
  $: shouldDisplayModal = $modal != null;
</script>

{#if shouldDisplayModal}
  <div
    id="modal-warning"
    class="
      fixed
      inset-0
      bg-black/70
      backdrop-blur-md
      backdrop-saturate-150
      flex
      items-center
      justify-center
      p-4
      text-center
      z-50
      focus:outline-none
      overflow-y-auto
    "
  >
    <div class="
        relative
        transform
        overflow-hidden
        rounded-2xl
        shadow-2xl
        bg-gray-900
        border-2
        border-red-500
        ring-1
        ring-white/20
        p-8
        sm:pt-10
        text-left
        sm:my-8
        sm:w-full
        sm:max-w-lg
        max-h-[90vh]
        animate-modal
      ">
      <div class="
        sm:flex
        sm:items-start
      ">
        <div class="
          mx-auto
          flex
          size-16
          shrink-0
          items-center
          justify-center
          rounded-full
          shadow-lg
          sm:mx-0
          sm:size-16
        ">
          <img
            class="mx-auto h-16 object-contain"
            src="/icons/warning.svg"
            alt="Warning"
          />
        </div>
        <div class="mt-3 sm:mt-0 sm:ml-6 sm:text-left flex flex-col w-full">
          <h3
            id="dialog-title"
            class="
              text-center
              sm:text-left
              text-lg
              font-bold
              text-white
              tracking-wide
              flex-shrink-0
            "
          >
            {$modal?.description.title}
          </h3>
          <div
            class="mt-3 overflow-y-auto flex-1 min-h-0"
            style="max-height: 32vh;"
          >
            <p class="
              text-base
              text-gray-300
              font-medium
            ">
              {$modal?.description.text}
            </p>
            <ul class="
              list-disc
              list-outside
              ml-6
              text-gray-300
              text-base
              pt-3
              space-y-1
            ">
              {#each $modal?.description.bullets as bullet}
                <li>{bullet}</li>
              {/each}
            </ul>
          </div>
        </div>
      </div>
      <div class="
        mt-7
        sm:mt-6
        sm:flex
        sm:flex-row-reverse
        gap-3
      ">
        <button
          on:click={$modal?.buttons.negative_event}
          type="button"
          class="
            flex
            w-full
            justify-center
            rounded-md
            bg-re-500
            px-4
            py-1.5
            mb-4
            text-sm/6
            font-semibold
            text-white
            bg-red-500
            hover:bg-red-400
            hover:scale-105
            transition-all
            focus-visible:ring-2
            focus-visible:ring-indigo-400
            sm:mb-0
            sm:ml-3
            sm:w-auto
          "
        >
          {$modal?.buttons.negative_label}
        </button>
        <button
          on:click={$modal?.buttons.positive_event}
          type="button"
          class="
            flex
            w-full
            justify-center
            rounded-md
            px-4
            py-1.5
            text-sm/6
            font-semibold
            text-white
            bg-indigo-500
            hover:bg-indigo-400
            shadow-md
            ring-1
            ring-white/10
            hover:scale-105
            transition-all
            focus-visible:ring-2
            focus-visible:ring-indigo-400
            sm:ml-0
            sm:w-auto
          "
        >
          {$modal?.buttons.positive_label}
        </button>
      </div>
    </div>
  </div>
{/if}

Create a new file named modal.ts inside the directory src/components in your Tauri project and populate it with the below content:

modal.ts
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: {
      positive_label: "Use account",
      negative_label: "Continue without account",
      positive_event: () => {
        modal.set(null);
      },
      negative_event: () => {
        modal.set(null);
      }
    }
  });
}

Create a new file named toast.svelte inside the directory src/components in your Tauri project and populate it with the below content:

toast.svelte
<script lang="ts">
  import { toastInfo, toastError } from "./toast";

  let infoMessage = null;
  let errorMessage = null;

  $: infoMessage = $toastInfo;
  $: errorMessage = $toastError;
</script>

{#if infoMessage}
  <div
    id="toast-info"
    class="
      fixed
      top-6
      left-1/2
      -translate-x-1/2
      bg-black/70
      text-white
      px-6
      py-3
      gap-3
      z-50
      rounded-xl
      shadow-2xl
      flex
      items-center
      justify-center
      min-w-[240px]
      animate-toast
      border-2
      border-green-500
      backdrop-blur-md
      backdrop-saturate-150
      ring-1 ring-white/20
      "
    style="box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);"
  >
    <svg
      class="h-8 w-8 text-green-400 drop-shadow"
      fill="none"
      viewBox="0 0 24 24"
      stroke="currentColor"
    >
      <path
        stroke-linecap="round"
        stroke-linejoin="round"
        stroke-width="2"
        d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
      />
    </svg>
    <span class="font-semibold tracking-wide">
      {infoMessage}
    </span>
  </div>
{/if}

{#if errorMessage}
  <div
    id="toast-error"
    class="
      fixed
      top-6
      left-1/2
      -translate-x-1/2
      bg-black/70
      text-white
      px-6
      py-3
      gap-3
      z-50
      rounded-xl
      shadow-2xl
      flex
      items-center
      justify-center
      min-w-[240px]
      animate-toast
      border-2
      border-red-500
      backdrop-blur-md
      backdrop-saturate-150
      ring-1 ring-white/20
      "
    style="box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);"
  >
    <svg
      class="h-8 w-8 text-red-400 drop-shadow"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    >
      <circle cx="12" cy="12" r="10" />
      <line x1="15" y1="9" x2="9" y2="15" />
      <line x1="9" y1="9" x2="15" y2="15" />
    </svg>
    <span class="font-semibold tracking-wide">
      {errorMessage}
    </span>
  </div>
{/if}

Create a new file named toast.ts inside the directory src/components in your Tauri project and populate it with the below content:

toast.ts
import { writable } from "svelte/store";

export const toastInfo = writable(null);
export const toastError = writable(null);

export function initializeToast() {
  const hiddenClass = "hidden";

  const toastIds = ["toast-info", "toast-error"];

  toastIds.forEach(id => {
    const toast = document.getElementById(id) as HTMLElement | null;
    if (!toast) return;

    toast.classList.remove(hiddenClass);
    const onAnimationEnd = () => {
      toast.removeEventListener('animationend', onAnimationEnd);
      toast.classList.add(hiddenClass);
    };
    toast.addEventListener('animationend', onAnimationEnd);
  });
}

Create a new file named +layout.svelte inside the directory src/routes in your Tauri project and populate it with the below content:

+layout.svelte
<script>
  import { initializeToast, toastInfo, toastError } from "../components/toast";
  import { is_verifying } from "../ts/otp";
  import { listen } from "@tauri-apps/api/event";
  import { loading } from "../components/loading-spinner";
  import Modal from "../components/modal.svelte";
  import { onMount, tick } from "svelte";
  import Toast from '../components/toast.svelte';

  let { children } = $props();
  import "../app.css";

  onMount(() => {
    listen("Success", async (event) => {
      toastError.set(null);
      toastInfo.set(event.payload || "Success");
      loading.set(null);

      if (event.payload === "Code sent") {
        is_verifying.set(true);
      } else {
        is_verifying.set(false);
      }

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

    listen("Error", async (event) => {
      toastInfo.set(null);
      toastError.set(event.payload || "Error");
      loading.set(null);

      await tick();
      initializeToast();
    });
  });
</script>

<Toast />
<Modal />

{@render children()}

Replace the content of the file +page.svelte located in src/routes with the below:

+page.svelte
<script lang="ts">
  import { initializeCodeInput } from '../ts/code-input';
  import { is_verifying, otp_code } from '../ts/otp';
  import { loading } from '../components/loading-spinner';
  import {
    loginWithOAuth,
    loginWithOTP,
    verifyOTPCode
  } from '../ts/authentication';
  import LoadingSpinner from '../components/loading-spinner.svelte';
  import { showNoAccountModal } from '../components/modal';
  import { tick } from 'svelte';

  let container: HTMLDivElement;

  $: if ($is_verifying && container) {
    tick().then(() => {
      initializeCodeInput(container);
    });
  }
</script>

<div
  class="
    min-h-screen
    bg-gradient-to-br
    from-indigo-950
    via-gray-900
    to-fuchsia-950"
>
  <div
    class="min-h-screen flex flex-col justify-center px-6 py-12 lg:px-8"
    id="container"
  >
   <div
    class="
      sm:mx-auto
      sm:w-full
      sm:max-w-sm
      lg:max-w-4xl
      lg:w-4/5
      lg:flex
      lg:items-center
      lg:justify-center"
    id="sub-container"
    >
      <div class="
        hidden
        lg:flex
        lg:flex-col
        lg:justify-center
        lg:items-center
        lg:w-1/2
        lg:pr-12"
      >
        <img
          class="mx-auto h-32 w-auto"
          src="/logo.png"
          alt="Epic Fantasy Forge"
        />
        <h2 class="mt-8 text-4xl font-extrabold text-white text-center ">
          Account Benefits
        </h2>
        <dl class="
          mt-10
          max-w-xl
          space-y-8
          text-base/7
          text-gray-300
          lg:max-w-none"
        >
          <div class="relative pl-9">
            <dt class="inline font-semibold text-white">
              <svg
                class="absolute top-1 left-0 size-5 text-indigo-500"
                viewBox="0 0 20 20"
                fill="currentColor"
                aria-hidden="true"
                data-slot="icon"
              >
                <path
                  fill-rule="evenodd"
                  d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
                  clip-rule="evenodd"
                />
              </svg>
              Cloud storage
            </dt>
          </div>
          <div class="relative pl-9">
            <dt class="inline font-semibold text-white">
              <svg
                class="absolute top-1 left-0 size-5 text-indigo-500"
                viewBox="0 0 20 20"
                fill="currentColor"
                aria-hidden="true"
                data-slot="icon"
              >
                <path
                  fill-rule="evenodd"
                  d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
                  clip-rule="evenodd"
                />
              </svg>
              Sync worlds across devices
            </dt>
          </div>
          <div class="relative pl-9">
            <dt class="inline font-semibold text-white">
              <svg
                class="absolute top-1 left-0 size-5 text-indigo-500"
                viewBox="0 0 20 20"
                fill="currentColor"
                aria-hidden="true"
                data-slot="icon"
              >
                <path
                  fill-rule="evenodd"
                  d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
                  clip-rule="evenodd"
                />
              </svg>
              Share your worlds with others
            </dt>
          </div>
        </dl>
      </div>
      <div class="w-full lg:w-1/2">
        <div class="sm:mx-auto sm:w-full sm:max-w-sm relative">
          <div class="
            lg:bg-gray-900
            lg:rounded-2xl
            lg:shadow-2xl
            lg:backdrop-blur-xl
            lg:border
            lg:border-white/10
            lg:p-10
            lg:relative
            lg:overflow-hidden"
          >
            <img
            class="mx-auto h-24 mb-6 w-auto lg:hidden"
            src="/logo.png"
            alt="Epic Fantasy Forge"
            />
            <h2 class="
              text-center
              text-2xl/9
              font-bold
              tracking-tight
              text-white
              mb-2"
            >
              Log in with:
            </h2>
            <div class="space-y-8">
              <div>
                <div class="mt-6 grid grid-cols-2 gap-4">
                  <button
                    class="
                      flex
                      w-full
                      items-center
                      justify-center
                      gap-2
                      rounded-md
                      bg-white/90
                      px-3
                      py-2
                      text-sm
                      font-semibold
                      text-gray-900
                      shadow-md
                      ring-1
                      ring-gray-300
                      ring-inset
                      hover:bg-gray-50
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2 
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    on:click={() => loginWithOAuth('gitlab')}
                    disabled={$loading != null}
                  >
                    <img
                      class="mr-3 size-5"
                      src="/logos/gitlab.png"
                      alt="Sign in with GitLab"
                    />
                    <span class="text-sm/6 font-semibold">GitLab</span>
                    <span style="display: inline-block; width: 24px; height: 24px;">
                      {#if $loading === 'gitlab'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                  <button
                    class="
                      flex
                      w-full
                      items-center
                      justify-center
                      gap-2
                      rounded-md
                      bg-white/90
                      px-3
                      py-2
                      text-sm
                      font-semibold
                      text-gray-900
                      shadow-md
                      ring-1
                      ring-gray-300
                      ring-inset
                      hover:bg-gray-50
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    on:click={() => loginWithOAuth('github')}
                    disabled={$loading != null}
                  >
                    <svg
                      class="mr-3 size-5 fill-[#24292F]"
                      fill="currentColor"
                      viewBox="0 0 20 20"
                      aria-hidden="true"
                    >
                      <path
                        fill-rule="evenodd"
                        d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
                        clip-rule="evenodd"
                      />
                    </svg>
                    <span class="text-sm/6 font-semibold">GitHub</span>
                    <span style="display: inline-block; width: 24px; height: 24px;">
                      {#if $loading === 'github'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                  <button
                    class="
                      flex
                      w-full
                      items-center
                      justify-center
                      gap-2
                      rounded-md
                      bg-white/90
                      px-3
                      py-2
                      text-sm
                      font-semibold
                      text-gray-900
                      shadow-md
                      ring-1
                      ring-gray-300
                      ring-inset
                      hover:bg-gray-50
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    on:click={() => loginWithOAuth('google')}
                    disabled={$loading != null}
                  >
                    <svg
                      class="mr-3 h-5 w-5"
                      viewBox="0 0 24 24"
                      aria-hidden="true"
                    >
                      <path
                        d="M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z"
                        fill="#EA4335"
                      />
                      <path
                        d="M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z"
                        fill="#4285F4"
                      />
                      <path
                        d="M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z"
                        fill="#FBBC05"
                      />
                      <path
                        d="M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z"
                        fill="#34A853"
                      />
                    </svg>
                    <span class="text-sm/6 font-semibold">Google</span>
                    <span style="display: inline-block; width: 24px; height: 24px;">
                      {#if $loading === 'google'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                  <button
                    class="
                      flex
                      w-full
                      items-center
                      justify-center
                      gap-2
                      rounded-md
                      bg-white/90
                      px-3
                      py-2
                      text-sm
                      font-semibold
                      text-gray-900
                      shadow-md
                      ring-1
                      ring-gray-300
                      ring-inset
                      hover:bg-gray-50
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    on:click={() => loginWithOAuth('apple')}
                    disabled={$loading != null}
                  >
                    <img
                      class="mr-3 size-5"
                      src="/logos/apple.png"
                      alt="Log in with Apple"
                    />
                    <span class="text-sm/6 font-semibold">Apple</span>
                    <span style="display: inline-block; width: 24px; height: 24px;">
                      {#if $loading === 'apple'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                  <button
                    class="
                      flex
                      w-full
                      items-center
                      justify-center
                      gap-2
                      rounded-md
                      bg-white/90
                      px-3
                      py-2
                      text-sm
                      font-semibold
                      text-gray-900
                      shadow-md
                      ring-1
                      ring-gray-300
                      ring-inset
                      hover:bg-gray-50
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    on:click={() => loginWithOAuth('azure')}
                    disabled={$loading != null}
                  >
                    <img
                      class="mr-3 size-5"
                      src="/logos/microsoft.svg"
                      alt="Log in with Microsoft"
                    />
                    <span class="text-sm/6 font-semibold">Microsoft</span>
                    <span style="display: inline-block; width: 24px; height: 24px;">
                      {#if $loading === 'azure'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                  <button
                    class="
                      flex
                      w-full
                      items-center
                      justify-center
                      gap-2
                      rounded-md
                      bg-white/90
                      px-3
                      py-2
                      text-sm
                      font-semibold
                      text-gray-900
                      shadow-md
                      ring-1
                      ring-gray-300
                      ring-inset
                      hover:bg-gray-50
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    on:click={() => loginWithOAuth('discord')}
                    disabled={$loading != null}
                  >
                    <img
                      class="mr-3 size-5"
                      src="/logos/discord.svg"
                      alt="Log in with Discord"
                    />
                    <span class="text-sm/6 font-semibold">Discord</span>
                    <span style="display: inline-block; width: 24px; height: 24px;">
                      {#if $loading === 'discord'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                </div>
              </div>
              <div class="flex items-center my-4">
                <div class="flex-grow border-t border-gray-400/40"></div>
                <span class="mx-4 text-gray-300 text-sm">or</span>
                <div class="flex-grow border-t border-gray-400/40"></div>
              </div>
              <form
                class="space-y-4"
                on:submit={$is_verifying ? verifyOTPCode : loginWithOTP}
              >
                <div>
                  <label
                    for="email"
                    class="block text-sm/6 font-medium text-white"
                  >
                    {#if $is_verifying}
                      Enter the 6 digit code
                    {:else}
                      Log in or create account with email
                    {/if}
                  </label>
                  <div class="mt-2">
                    {#if $is_verifying}
                      <div
                        class="flex gap-2 justify-center w-full mx-auto"
                        bind:this={container}
                      >
                        {#each Array(6) as _, i}
                          <input
                            type="text"
                            inputmode="numeric"
                            pattern="[0-9]*"
                            maxlength="1"
                            name={`code[${i}]`}
                            id={`code_${i}`}
                            otp-digit
                            class="
                              flex-1
                              min-w-0
                              h-12
                              text-center
                              rounded-md
                              bg-white/10
                              text-white
                              text-2xl
                              outline-1
                              outline-white/10
                              placeholder:text-gray-300
                              focus:outline-2
                              focus:outline-indigo-500
                              transition-all"
                            autocomplete="one-time-code"
                            required
                            bind:value={$otp_code[i]}
                          />
                        {/each}
                      </div>
                    {:else}
                      <input
                        type="email"
                        name="email"
                        id="email"
                        autocomplete="email"
                        required
                        class="
                          block
                          w-full
                          rounded-md
                          bg-white/10
                          px-3
                          py-1.5
                          text-base
                          text-white
                          outline-1
                          -outline-offset-1 
                          outline-white/10
                          placeholder:text-gray-300
                          focus:outline-2
                          focus:-outline-offset-2
                          focus:outline-indigo-500 sm:text-sm/6"
                        placeholder="user@example.com"
                      />
                    {/if}
                  </div>
                </div>
                <div>
                  <button
                    type="submit"
                    class="
                      relative
                      flex
                      w-full
                      mx-auto
                      justify-center
                      items-center
                      rounded-md
                      bg-indigo-500
                      px-3
                      py-1.5
                      text-sm/6
                      font-semibold
                      text-white
                      shadow-md
                      hover:bg-indigo-400
                      hover:scale-105
                      transition-all
                      focus-visible:ring-2
                      focus-visible:ring-indigo-400
                      cursor-pointer"
                    disabled={$loading != null}
                  >
                    {#if $is_verifying}
                      Verify code
                    {:else}
                      Send login code
                    {/if}
                    <span
                      class="absolute right-4 flex items-center"
                      style="width: 24px; height: 24px;"
                    >
                      {#if $loading === 'otp'}
                        <LoadingSpinner />
                      {/if}
                    </span>
                  </button>
                </div>
              </form>
              <div class="flex items-center my-4">
                <div class="flex-grow border-t border-gray-400/40"></div>
                <span class="mx-4 text-gray-300 text-sm">or</span>
                <div class="flex-grow border-t border-gray-400/40"></div>
              </div>
              <div class="flex justify-center">
              <form class="w-full">
                <button
                  class="
                    flex
                    w-full
                    justify-center
                    rounded-md
                    bg-indigo-500
                    px-4
                    py-1.5
                    text-sm/6
                    font-semibold
                    text-white
                    shadow-md
                    hover:bg-indigo-400
                    hover:scale-105
                    transition-all
                    focus-visible:ring-2
                    focus-visible:ring-indigo-400
                    cursor-pointer"
                  on:click={showNoAccountModal}
                  disabled={$loading != null}
                >
                  Continue without account
                </button>
              </form>
            </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

Create a new file named authentication.ts inside the directory src/ts in your Tauri project and populate it with the below content:

authentication.ts
import { get } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
import { loading } from '../components/loading-spinner';
import { otp_code } from './otp';

export function loginWithOAuth(provider: string) {
  loading.set(provider);
  invoke('tauri_login_with_oauth', { provider });
}

export function loginWithOTP(event: Event) {
  loading.set("otp");
  event.preventDefault();

  const form = event.target as HTMLFormElement;
  const emailInput = form.elements.namedItem('email') as HTMLInputElement;

  if (emailInput && emailInput.value) {
    const email = emailInput.value;
    invoke('tauri_login_with_otp', { email });
  }
}

export function verifyOTPCode(event: Event) {
  loading.set("otp");
  event.preventDefault();

  const code = get(otp_code).join("");
  invoke('tauri_verify_otp_code', { code });
}

Create a new file named code-input.ts inside the directory src/ts in your Tauri project and populate it with the below content:

code-input.ts
import { otp_code } from "./otp";

export function initializeCodeInput(container: HTMLElement) {
  new CodeInput(container);
}

class CodeInput {
  private static readonly otpRegex = /^\d{6}$/;
  private static readonly isDigitRegex = /^\d$/;

  private inputs: HTMLInputElement[];

  constructor(container: HTMLElement) {
    this.inputs = Array.from(
      container.querySelectorAll<HTMLInputElement>("input[otp-digit]"));

    this.inputs.forEach((input, index) => {
      input.oninput = () => this.onInput(index);
      input.onkeydown = (event) => this.onKeyDown(index, event);
      input.onfocus = () => { input.value = ""; };
      input.onpaste = (event) => this.onPaste(event);
    });
  }

  private onInput(index: number) {
    const input = this.inputs[index];

    if (CodeInput.isDigitRegex.test(input.value)) {
      if (index + 1 < this.inputs.length) {
        this.inputs[index + 1].focus();
      } else {
        input.blur();
      }
    } else {
      input.value = '';
    }
  }

  private onKeyDown(index: number, keyboardEvent: KeyboardEvent) {
    if (keyboardEvent.key === "Backspace") {
       otp_code.update(codes => {
         codes[index] = '';
         return codes;
       });

       if (index > 0) {
         this.inputs[index - 1].focus();
       }
    }
  }

  // I have not figured out a way how to test this with Jest. When trying to
  // simulate a ClipboardEvent in a Jest test, it gives the below error:
  // ReferenceError: ClipboardEvent is not defined
  private onPaste(clipboardEvent: ClipboardEvent) {
    const pastedText = clipboardEvent.clipboardData?.getData("text") ?? "";

    if (CodeInput.otpRegex.test(pastedText)) {
      clipboardEvent.preventDefault();

      pastedText.split("").forEach((char, index) => {
        otp_code.update(codes => {
          codes[index] = char;
          return codes;
        });

        if (this.inputs[index]) {
          this.inputs[index].value = char;
          this.inputs[index].blur();
        }
      });
    }
  }
}

Create a new file named otp.ts inside the directory src/ts in your Tauri project and populate it with the below content:

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

export const is_verifying = writable<boolean>(false);
export const otp_code = writable<string[]>(['', '', '', '', '', '']);

Now that you have a proper screen on your app, you may reconsider taking a new screenshot of the app for the app-screenshot.png displayed on the home page.