feature: password reset

This commit is contained in:
asterisk727 2025-07-26 01:20:07 -05:00 committed by Azalea
parent e0d12acf61
commit 82adf5c138
8 changed files with 496 additions and 12 deletions

View File

@ -80,6 +80,7 @@
<Router {url}> <Router {url}>
<Route path="/" component={Welcome} /> <Route path="/" component={Welcome} />
<Route path="/verify" component={Welcome} /> <!-- For email verification only, backwards compatibility with AquaNet2 in the future --> <Route path="/verify" component={Welcome} /> <!-- For email verification only, backwards compatibility with AquaNet2 in the future -->
<Route path="/reset-password" component={Welcome} />
<Route path="/home" component={Home} /> <Route path="/home" component={Home} />
<Route path="/ranking" component={Ranking} /> <Route path="/ranking" component={Ranking} />
<Route path="/ranking/:game" component={Ranking} /> <Route path="/ranking/:game" component={Ranking} />

View File

@ -34,21 +34,29 @@ export const EN_REF_Welcome = {
'back': 'Back', 'back': 'Back',
'email': 'Email', 'email': 'Email',
'password': 'Password', 'password': 'Password',
'new-password': 'New password',
'username': 'Username', 'username': 'Username',
'welcome.btn-login': 'Log in', 'welcome.btn-login': 'Log in',
'welcome.btn-signup': 'Sign up', 'welcome.btn-signup': 'Sign up',
'welcome.btn-reset-password': 'Forgot password?',
'welcome.btn-submit-reset-password': 'Send reset link',
'welcome.btn-submit-new-password': 'Change password',
'welcome.email-missing': 'Email is required',
'welcome.password-missing': 'Password is required',
'welcome.email-password-missing': 'Email and password are required', 'welcome.email-password-missing': 'Email and password are required',
'welcome.username-missing': 'Username/email is required', 'welcome.username-missing': 'Username/email is required',
'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...', 'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...',
'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.', 'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.',
'welcome.turnstile-timeout': 'Network verification timed out. Please try again.', 'welcome.turnstile-timeout': 'Network verification timed out. Please try again.',
'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!', 'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!',
'welcome.reset-password-sent': 'A password reset email has been sent to ${email}. Please check your inbox!',
'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!', 'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!',
'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!', 'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!',
'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!', 'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!',
'welcome.verifying': 'Verifying your email... please wait.', 'welcome.verifying': 'Verifying your email... please wait.',
'welcome.verified': 'Your email has been verified! You can now log in now.', 'welcome.verified': 'Your email has been verified! You can now log in now.',
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.', 'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
'welcome.password-reset-done': 'Your password has been changed! You can log in now.',
} }
export const EN_REF_LEADERBOARD = { export const EN_REF_LEADERBOARD = {

View File

@ -163,12 +163,22 @@ async function login(user: { email: string, password: string, turnstile: string
localStorage.setItem('token', data.token) localStorage.setItem('token', data.token)
} }
async function resetPassword(user: { email: string, turnstile: string }) {
return await post('api/v2/user/reset-password', user)
}
async function changePassword(user: { code: string, password: string }) {
return await post('/api/v2/user/change-password', user)
}
const isLoggedIn = () => !!localStorage.getItem('token') const isLoggedIn = () => !!localStorage.getItem('token')
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/') const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
export const USER = { export const USER = {
register, register,
login, login,
resetPassword,
changePassword,
confirmEmail: (token: string) => confirmEmail: (token: string) =>
post('/api/v2/user/confirm-email', { token }), post('/api/v2/user/confirm-email', { token }),
me: (): Promise<AquaNetUser> => { me: (): Promise<AquaNetUser> => {

View File

@ -20,31 +20,33 @@
let error = "" let error = ""
let verifyMsg = "" let verifyMsg = ""
let code = ""
if (USER.isLoggedIn()) { if (USER.isLoggedIn()) {
window.location.href = "/home" window.location.href = "/home"
} }
if (location.pathname !== '/') { if (params.get('code')) {
location.href = `/${params.get('code') ? `?code=${params.get('code')}` : ""}` code = params.get('code')!
} else if (location.pathname === '/verify') {
if (params.get('code')) {
state = 'verify' state = 'verify'
verifyMsg = t("welcome.verifying") verifyMsg = t("welcome.verifying")
submitting = true submitting = true
// Send request to server // Send request to server
USER.confirmEmail(params.get('code')!) USER.confirmEmail(code)
.then(() => { .then(() => {
verifyMsg = t('welcome.verified') verifyMsg = t('welcome.verified')
submitting = false submitting = false
// Clear the query param // Clear the query param
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
}) })
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message })) .catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
} }
else if (location.pathname === '/reset-password') {
state = 'reset'
}
}
async function submit(): Promise<any> { async function submit(): Promise<any> {
submitting = true submitting = true
@ -111,6 +113,52 @@ if (location.pathname !== '/') {
submitting = false submitting = false
} }
async function resetPassword(): Promise<any> {
submitting = true;
if (email === "") {
error = t("welcome.email-missing")
return submitting = false
}
// Send request to server
await USER.resetPassword({ email, turnstile })
.then(() => {
// Show email sent message, reusing email verify page
state = 'verify'
verifyMsg = t("welcome.reset-password-sent", { email })
})
.catch(e => {
error = e.message
submitting = false
turnstileReset()
})
submitting = false;
}
async function changePassword(): Promise<any> {
submitting = true
if (password === "") {
error = t("welcome.password-missing")
return submitting = false
}
// Send request to server
await USER.changePassword({ code, password })
.then(() => {
verifyMsg = t("welcome.password-reset-done")
})
.catch(e => {
error = e.message
submitting = false
turnstileReset()
})
submitting = false
}
</script> </script>
<main id="home" class="no-margin"> <main id="home" class="no-margin">
@ -143,6 +191,9 @@ if (location.pathname !== '/') {
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')} {isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
{/if} {/if}
</button> </button>
{#if !submitting}
<button on:click={() => state = 'submitreset'}>{t('welcome.btn-reset-password')}</button>
{/if}
{#if TURNSTILE_SITE_KEY} {#if TURNSTILE_SITE_KEY}
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset} <Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)} on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
@ -151,6 +202,32 @@ if (location.pathname !== '/') {
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} /> on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
{/if} {/if}
</div> </div>
{:else if state === "submitreset"}
<div class="login-form" transition:slide>
{#if error}
<span class="error">{error}</span>
{/if}
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
role="button" tabindex="0" class="clickable">
<Icon icon="line-md:chevron-small-left" />
<span>{t('back')}</span>
</div>
<input type="email" placeholder={t('email')} bind:value={email}>
<button on:click={resetPassword}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('welcome.btn-submit-reset-password')}
{/if}
</button>
{#if TURNSTILE_SITE_KEY}
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
on:turnstile-expired={_ => window.location.reload()}
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
{/if}
</div>
{:else if state === "verify"} {:else if state === "verify"}
<div class="login-form" transition:slide> <div class="login-form" transition:slide>
<span>{verifyMsg}</span> <span>{verifyMsg}</span>
@ -158,6 +235,17 @@ if (location.pathname !== '/') {
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button> <button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
{/if} {/if}
</div> </div>
{:else if state === "reset"}
<div class="login-form" transition:slide>
<input type="password" placeholder={t('new-password')} bind:value={password}>
<button on:click={changePassword}>
{#if submitting}
<Icon icon="line-md:loading-twotone-loop"/>
{:else}
{t('welcome.btn-submit-new-password')}
{/if}
</button>
</div>
{/if} {/if}
</div> </div>

View File

@ -29,6 +29,7 @@ class UserRegistrar(
val geoIP: GeoIP, val geoIP: GeoIP,
val jwt: JWT, val jwt: JWT,
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo,
val cardRepo: CardRepository, val cardRepo: CardRepository,
val cardService: CardService, val cardService: CardService,
val validator: AquaUserServices, val validator: AquaUserServices,
@ -144,6 +145,53 @@ class UserRegistrar(
return mapOf("token" to token) return mapOf("token" to token)
} }
@API("/reset-password")
@Doc("Reset password with a token sent through email to the user, if it exists.", "Success message") // wtf is the second param in this annotation?
suspend fun resetPassword(
@RP email: Str, @RP turnstile: Str,
request: HttpServletRequest
) : Any {
// Check captcha
val ip = geoIP.getIP(request)
log.info("Net: /user/reset-password from $ip : $email")
if (!turnstileService.validate(turnstile, ip)) 400 - "Invalid captcha"
// Check if user exists, treat as email / username
val user = async { userRepo.findByEmailIgnoreCase(email) ?: userRepo.findByUsernameIgnoreCase(email) }
?: return SUCCESS // obviously dont tell them if the email exists or not
// Check if email is verified
if (!user.emailConfirmed && emailProps.enable) 400 - "Email not verified" // maybe similar logic to login here
// Send a password reset email
emailService.sendPasswordReset(user)
return SUCCESS
}
@API("/change-password")
@Doc("Change a user's password given a reset code", "Success message") // again have no idea what it is
suspend fun changePassword(
@RP token: Str, @RP password: Str,
request: HttpServletRequest
) : Any {
// Find the reset token
val reset = async { resetPasswordRepo.findByToken(token) }
// Check if the token is valid
if (reset == null) 400 - "Invalid token"
// Check if the token is expired
if (reset.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired"
// Change the password
async { userRepo.save(reset.aquaNetUser.apply { pwHash = validator.checkPwHash(password) }) } // how...
return SUCCESS
}
@API("/confirm-email") @API("/confirm-email")
@Doc("Confirm email address with a token sent through email to the user.", "Success message") @Doc("Confirm email address with a token sent through email to the user.", "Success message")
suspend fun confirmEmail(@RP token: Str): Any { suspend fun confirmEmail(@RP token: Str): Any {

View File

@ -5,6 +5,7 @@ import ext.Str
import ext.logger import ext.logger
import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.EmailConfirmation import icu.samnyan.aqua.net.db.EmailConfirmation
import icu.samnyan.aqua.net.db.PasswordReset
import icu.samnyan.aqua.net.db.EmailConfirmationRepo import icu.samnyan.aqua.net.db.EmailConfirmationRepo
import org.simplejavamail.api.mailer.Mailer import org.simplejavamail.api.mailer.Mailer
import org.simplejavamail.email.EmailBuilder import org.simplejavamail.email.EmailBuilder
@ -38,10 +39,13 @@ class EmailService(
val mailer: Mailer, val mailer: Mailer,
val props: EmailProperties, val props: EmailProperties,
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo,
) { ) {
val log = logger() val log = logger()
val confirmTemplate: Str = this::class.java.getResource("/email/confirm.html")?.readText() val confirmTemplate: Str = this::class.java.getResource("/email/confirm.html")?.readText()
?: throw Exception("Email Template Not Found") ?: throw Exception("Email Confirm Template Not Found")
val resetTemplate: Str = this::class.java.getResource("/email/reset.html")?.readText()
?: throw Exception("Password Reset Template Not Found")
@Async @Async
@EventListener(ApplicationStartedEvent::class) @EventListener(ApplicationStartedEvent::class)
@ -80,6 +84,26 @@ class EmailService(
.buildEmail()).thenRun { log.info("Verification email sent to ${user.email}") } .buildEmail()).thenRun { log.info("Verification email sent to ${user.email}") }
} }
fun sendPasswordReset (user: AquaNetUser) {
if (!props.enable) return
// Generate token (UUID4)
val token = UUID.randomUUID().toString()
val reset = ResetPassword(token = token, aquaNetUser = user, createdAt = Date().toInstant())
resetPasswordRepo.save(reset)
// Send email
log.info("Sending reset password email to ${user.email}")
mailer.sendMail(EmailBuilder.startingBlank()
.from(props.senderName, props.senderAddr)
.to(user.computedName, user.email)
.withSubject("Reset Your Password for AquaNet")
.withHTMLText(resetTemplate
.replace("{{name}}", user.computedName)
.replace("{{url}}", "https://${props.webHost}/reset-password?code=$token"))
.buildEmail()).thenRun { log.info("Reset password email sent to ${user.email}") }
}
fun testEmail(addr: Str, name: Str) { fun testEmail(addr: Str, name: Str) {
if (!props.enable) return if (!props.enable) return

View File

@ -0,0 +1,33 @@
package icu.samnyan.aqua.net.db
import jakarta.persistence.*
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.io.Serializable
import java.time.Instant
@Entity
@Table(name = "aqua_net_email_reset_password")
class ResetPassword(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0,
@Column(nullable = false)
var token: String = "",
// Token creation time
@Column(nullable = false)
var createdAt: Instant = Instant.now(),
// Linking to the AquaNetUser
@ManyToOne
@JoinColumn(name = "auId", referencedColumnName = "auId")
var aquaNetUser: AquaNetUser = AquaNetUser()
) : Serializable
@Repository
interface ResetPasswordRepo : JpaRepository<ResetPassword, Long> {
fun findByToken(token: String): ResetPassword?
fun findByAquaNetUserAuId(auId: Long): List<ResetPassword>
}

View File

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<title></title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/><!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]--><!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100;200;300;400;500;600;700;800;900" rel="stylesheet" type="text/css"/><!--<![endif]-->
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
}
p {
line-height: inherit
}
.desktop_hide,
.desktop_hide table {
mso-hide: all;
display: none;
max-height: 0px;
overflow: hidden;
}
.image_block img+div {
display: none;
}
@media (max-width:700px) {
.desktop_hide table.icons-inner,
.row-3 .column-1 .block-3.button_block .alignment a,
.row-3 .column-1 .block-3.button_block .alignment div {
display: inline-block !important;
}
.icons-inner {
text-align: center;
}
.icons-inner td {
margin: 0 auto;
}
.image_block div.fullWidth {
max-width: 100% !important;
}
.mobile_hide {
display: none;
}
.row-content {
width: 100% !important;
}
.stack .column {
width: 100%;
display: block;
}
.mobile_hide {
min-height: 0;
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0px;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important;
}
.row-1 .column-1 .block-1.icons_block .alignment,
.row-3 .column-1 .block-3.button_block .alignment {
text-align: center !important;
}
.row-3 .column-1 .block-2.paragraph_block td.pad>div {
text-align: left !important;
font-size: 14px !important;
}
.row-3 .column-1 .block-1.heading_block h1 {
text-align: center !important;
font-size: 24px !important;
}
.row-3 .column-1 .block-1.heading_block td.pad {
padding: 15px 0 !important;
}
.row-3 .column-1 .block-4.paragraph_block td.pad>div {
text-align: justify !important;
font-size: 10px !important;
}
.row-3 .column-1 .block-3.button_block a,
.row-3 .column-1 .block-3.button_block div,
.row-3 .column-1 .block-3.button_block span {
font-size: 14px !important;
line-height: 28px !important;
}
.row-3 .column-1 {
padding: 0 24px 48px !important;
}
}
</style>
</head>
<body style="background-color: #f8f6ff; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f6ff; background-image: none; background-position: top left; background-size: auto; background-repeat: no-repeat;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 32px; padding-left: 48px; padding-right: 48px; padding-top: 32px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="vertical-align: middle; color: white; font-family: inherit; font-size: 24px; font-weight: 400; letter-spacing: 6px; text-align: left;">
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="alignment" style="vertical-align: middle; text-align: left;"><!--[if vml]><table align="left" cellpadding="0" cellspacing="0" role="presentation" style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; display: inline-block; margin-right: -4px; padding-left: 0px; padding-right: 0px;"><!--<![endif]-->
<tr>
<td style="vertical-align: middle; text-align: center; padding-top: 0px; padding-bottom: 0px; padding-left: 0px; padding-right: 15px;"><img align="center" class="icon" height="32" src="https://aquadx.net/assets/icons/android-chrome-192x192.png" style="display: block; height: auto; margin: 0 auto; border: 0;" width="32"/></td>
<td style="font-family: 'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; font-size: 24px; font-weight: 400; color: white; vertical-align: middle; letter-spacing: 6px; text-align: left;">AquaDX</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; border-radius: 0; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="width:100%;padding-right:0px;padding-left:0px;">
<div align="center" class="alignment" style="line-height:10px">
<div class="fullWidth" style="max-width: 646px;"><img alt="" src="https://aquadx.net/assets/email/border.png" style="display: block; height: auto; border: 0; width: 100%;" title="An open email illustration" width="646"/></div>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-3" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: white; border-radius: 0; color: #000000; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 48px; padding-left: 48px; padding-right: 48px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="heading_block block-1" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="padding-bottom:12px;text-align:center;width:100%;">
<h1 style="margin: 0; color: #292929; direction: ltr; font-family: 'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif; font-size: 32px; font-weight: 700; letter-spacing: normal; line-height: 120%; text-align: left; margin-top: 0; margin-bottom: 0; mso-line-height-alt: 38.4px;"><span class="tinyMce-placeholder">Verify your email!</span></h1>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="paragraph_block block-2" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;" width="100%">
<tr>
<td class="pad" style="padding-bottom:10px;padding-top:10px;">
<div style="color:#101112;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:19.2px;">
<p style="margin: 0; margin-bottom: 16px;">Dear {{name}},</p>
<p style="margin: 0; margin-bottom: 16px;">You recently requested to reset your AquaDX password. To reset your password, please click the link below.</p>
<p style="margin: 0;">This link will allow you to reset your password, and it is valid for 24 hours. If you did not initiate this request, please ignore this email.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="button_block block-3" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tr>
<td class="pad" style="padding-bottom:15px;padding-top:15px;text-align:left;">
<div align="left" class="alignment"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{url}}" style="height:48px;width:168px;v-text-anchor:middle;" arcsize="17%" stroke="false" fillcolor="#646cff">
<w:anchorlock/>
<v:textbox inset="0px,0px,0px,0px">
<center style="color:#ffffff; font-family:'Trebuchet MS', Tahoma, sans-serif; font-size:16px">
<![endif]--><a href="{{url}}" style="text-decoration:none;display:inline-block;color:#ffffff;background-color:#646cff;border-radius:8px;width:auto;border-top:0px solid transparent;font-weight:400;border-right:0px solid transparent;border-bottom:0px solid transparent;border-left:0px solid transparent;padding-top:8px;padding-bottom:8px;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:16px;text-align:center;mso-border-alt:none;word-break:keep-all;" target="_blank"><span style="padding-left:16px;padding-right:16px;font-size:16px;display:inline-block;letter-spacing:normal;"><span style="word-break: break-word; line-height: 32px;">Verify email</span></span></a><!--[if mso]></center></v:textbox></v:roundrect><![endif]--></div>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="paragraph_block block-4" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;" width="100%">
<tr>
<td class="pad" style="padding-top:16px;">
<div style="color:#666666;direction:ltr;font-family:'Montserrat', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;font-size:12px;font-weight:400;letter-spacing:0px;line-height:120%;text-align:left;mso-line-height-alt:14.399999999999999px;">
<p style="margin: 0; margin-bottom: 12px;">If you're having trouble clicking the link, you can also copy and paste the URL below into your web browser:</p>
<p style="margin: 0;">{{url}}</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-4" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #99b2ff; color: #000000; width: 680px; margin: 0 auto;" width="680">
<tbody>
<tr>
<td class="column column-1" style="font-weight: 400; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding-bottom: 32px; padding-left: 48px; padding-right: 48px; padding-top: 32px; vertical-align: top; border-top: 0px; border-right: 0px; border-bottom: 0px; border-left: 0px;" width="100%">
<div class="spacer_block block-1" style="height:60px;line-height:60px;font-size:1px;"></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</body>
</html>