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
- Apple
- Microsoft
- Discord
- OTP
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.
Select "Authentication" from the sidebar on the left:
Select "Sign In / Providers":
Click on the OAuth provider in question (e.g. GitLab) from the list of "Auth Providers":
You can now see your callback URL. Copy it to your clipboard by clicking on the "Copy" button:
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.
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:
Select "Edit profile" from the drop-down menu:
Select "Applications" from the left sidebar:
Click on "Add new application":
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.
Scroll down and click "Save application":
Copy both the "Application ID" and "Secret" paste them into the appropriate fields in Supabase (next screenshot):
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.
You should now have an application configured in GitLab:
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":
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":
Next copy your "Client ID" and "Client secret":
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":
Now let's add a logo for your application. Click on "Upload new logo":
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.
Execute the above instructions for both your test and production environments. Finally you should have two OAuth apps registered in GitHub:
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":
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":
Enter your project name, e.g. "Epic Fantasy Forge Test", and location. Then click "Create":
Next select "APIs and services" from the left sidebar and click on "OAuth consent screen":
Click on "Get started":
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":
For "Audience", select "External". Then click "Next":
In the "Email addresses" field, enter your project's contact email, e.g. "henrik@epicfantasyforge.com". Then click "Next":
Read through the "Google API services user data policy". If you agree, check the checkbox and click "Continue" and/or "Create":
Now select "Branding" from the left sidebar:
Upload your app's logo:
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":
Now select "Data access" from the left sidebar and click "Add or remove scopes":
Select the below three scopes and click "Update":
- .../auth/userinfo.email
- .../auth/userinfo.profile
- openid
The scopes you select above should now be visible in the "Your non-sensitive scopes" section. Now click "Save":
Select "Clients" from the left sidebar and click "+ Create client":
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":
Copy your "Client ID" and "Client secret" and paste them into the relevant fields in 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":
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":
Execute the above instructions for both your test and production environments. Finally you should have two projects configured in Google Cloud:
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.
Next scroll back up to the "Program resources" section and click on "Service configuration":
Now click on "Configure" in the "Sign in with Apple for Email Communication" section:
Click on the "+" beside "Email Sources":
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":
Click "Register":
Click "Done":
Now your domain and email address should be listed under "Email Sources":
Now return to your Apple Developer Account and click on "Identifiers":
Select "App IDs" from the drop-down menu on the right and then click the "+" beside "Identifiers":
Select "App IDs" and click "Continue":
Select "App" and click "Continue":
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":
Scroll down and select "Sign in with Apple". Then click "Continue":
Click "Register":
You should now see your App ID Identifier listed:
Select "Services IDs" from the drop-down menu on the right and click on "Register an Services ID":
Select "Services IDs" and click "Continue":
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":
Click "Register":
Your new Identifier for "Services IDs" should now be listed. Click on your new Services ID:
Click on "Configure" beside "Sign In with Apple":
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":
Click "Done":
Now select "Keys" from the left sidebar and click on "Create a key":
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:
Select the App ID you created earlier for the field "Primary App ID". Then click "Save":
Now click "Continue":
Click "Register":
Next click "Download" to download your key.
Warning
Keep your key secret and store it in a secure place.
Now you should be able to see your key listed under "Keys":
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:
A secret key should now have been generated. Copy it to your clipboard:
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".
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":
Search for Entra in the search box and click on Microsoft Entra ID in the search results:
On the left sidebar, click on App registrations:
Click on + New registration:
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:
Now your app registration should have been created. Make note of your Application (client) ID and store it in a secure place.
Next open the Manage accordion on the left sidebar and select Certificates & secrets. Then click + New client secret:
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:
Now your client secret should have been created. Make note of the Value of your client secret and store it in a secure place.
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".
Back on the Microsoft Azure Certificates & secrets page, select Manifest from the left sidebar:
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": []
},
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:
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:
Next upload an icon for your app and optionally fill the description field. Then click Save Changes:
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:
Now click on Reset Secret:
On the prompt, click Yes, do it!:
Enter the Multi-Factor Authentication that you received and click Submit:
Now make note of your CLIENT SECRET and store it in a secure place.
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".
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.
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:
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:
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:
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.
Now select the SMTP Settings tab:
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:
Click on All settings:
From the left sidebar select Identity and addresses from the Proton Mail section. Then click on Add address:
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:
Your new email address should now be listed:
On the left sidebar select IMAP/SMTP and click on Generate token:
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:
Note down the SMTP token details and store them in a secure place. Then click Close:
Back on the Supabase SMTP Settings screen, enter the Proton Mail token details that you noted down earlier:
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.
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:
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:
# 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:
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:
{: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:
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"}:
[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:
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.
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:
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:
Now repeat the above steps for your production environment:
Now that these values are in the CI, update your deploy-test-environment.yml to pass these new environment variables:
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:
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:
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:
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 :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:
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:
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:
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:
- build-android.yml
- build-ios.yml
- build-linux-arm64.yml
- build-linux-x86-64.yml
- build-macos.yml
- build-windows.yml
- lint-app.yml
- test-app.yml
- test-web.yml
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:
- |
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:
"core:event:default",
"deep-link:default",
Additionally add the below configuration block to 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:
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
<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:
<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:
<%= 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:
<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:
<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:
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:
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:
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:
<%= 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
#![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:
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:
@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:
<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:
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:
<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:
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:
<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:
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:
<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:
<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:
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:
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:
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.




























































































































