-
Notifications
You must be signed in to change notification settings - Fork 187
feat: implement blocking email verification modal #2379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,36 +2,116 @@ | |
import { invalidate } from '$app/navigation'; | ||
import { Modal } from '$lib/components'; | ||
import { Button } from '$lib/elements/forms'; | ||
import Link from '$lib/elements/link.svelte'; | ||
import { addNotification } from '$lib/stores/notifications'; | ||
import { sdk } from '$lib/stores/sdk'; | ||
import { user } from '$lib/stores/user'; | ||
import { get } from 'svelte/store'; | ||
import { page } from '$app/state'; | ||
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte'; | ||
import { Dependencies } from '$lib/constants'; | ||
import { onMount } from 'svelte'; | ||
import { onMount, onDestroy } from 'svelte'; | ||
import { isCloud } from '$lib/system'; | ||
import { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard'; | ||
import { logout } from '$lib/helpers/logout'; | ||
import { browser } from '$app/environment'; | ||
|
||
let { show = $bindable(false) } = $props(); | ||
let creating = $state(false); | ||
let emailSent = $state(false); | ||
let resendTimer = $state(0); | ||
let timerInterval: ReturnType<typeof setInterval> | null = null; | ||
|
||
// Timer state key for localStorage | ||
const TIMER_END_KEY = 'email-verification-timer-end'; | ||
const EMAIL_SENT_KEY = 'email-verification-sent'; | ||
|
||
let cleanUrl = $derived(page.url.origin + page.url.pathname); | ||
|
||
async function onSubmit() { | ||
if (creating) return; | ||
// Determine if we should show the modal | ||
const hasUser = $derived(!!$user); | ||
const needsEmailVerification = $derived(hasUser && !$user.emailVerification); | ||
const notOnOnboarding = $derived(!page.route.id.includes('/onboarding')); | ||
const notOnWizard = $derived(!$wizard.show && !$isNewWizardStatusOpen); | ||
const shouldShowModal = $derived( | ||
isCloud && hasUser && needsEmailVerification && notOnOnboarding && notOnWizard | ||
); | ||
|
||
function startResendTimer() { | ||
const timerEndTime = Date.now() + 60 * 1000; | ||
resendTimer = 60; | ||
emailSent = true; | ||
|
||
if (browser) { | ||
localStorage.setItem(TIMER_END_KEY, timerEndTime.toString()); | ||
localStorage.setItem(EMAIL_SENT_KEY, 'true'); | ||
} | ||
|
||
startTimerCountdown(timerEndTime); | ||
} | ||
|
||
function restoreTimerState() { | ||
if (!browser) return; | ||
|
||
const savedTimerEnd = localStorage.getItem(TIMER_END_KEY); | ||
const savedEmailSent = localStorage.getItem(EMAIL_SENT_KEY); | ||
|
||
if (savedTimerEnd && savedEmailSent) { | ||
const timerEndTime = parseInt(savedTimerEnd); | ||
const now = Date.now(); | ||
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000)); | ||
|
||
if (remainingTime > 0) { | ||
resendTimer = remainingTime; | ||
emailSent = true; | ||
startTimerCountdown(timerEndTime); | ||
} else { | ||
// Timer has expired, clean up | ||
localStorage.removeItem(TIMER_END_KEY); | ||
localStorage.removeItem(EMAIL_SENT_KEY); | ||
resendTimer = 0; | ||
emailSent = false; | ||
} | ||
} | ||
} | ||
|
||
function startTimerCountdown(timerEndTime: number) { | ||
timerInterval = setInterval(() => { | ||
const now = Date.now(); | ||
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000)); | ||
|
||
resendTimer = remainingTime; | ||
|
||
if (remainingTime <= 0) { | ||
clearInterval(timerInterval); | ||
timerInterval = null; | ||
if (browser) { | ||
localStorage.removeItem(TIMER_END_KEY); | ||
localStorage.removeItem(EMAIL_SENT_KEY); | ||
} | ||
} | ||
}, 1000); | ||
} | ||
|
||
async function sendVerificationEmail() { | ||
if (creating || resendTimer > 0) return; | ||
creating = true; | ||
try { | ||
await sdk.forConsole.account.createVerification({ url: cleanUrl }); | ||
addNotification({ message: 'Verification email has been sent', type: 'success' }); | ||
emailSent = true; | ||
show = false; | ||
startResendTimer(); | ||
// Don't close modal - user needs to verify email first | ||
} catch (error) { | ||
addNotification({ message: error.message, type: 'error' }); | ||
} finally { | ||
creating = false; | ||
} | ||
} | ||
|
||
function onSubmit() { | ||
// This is required by the Modal component but we handle clicks directly | ||
} | ||
|
||
async function updateEmailVerification() { | ||
const searchParams = page.url.searchParams; | ||
const userId = searchParams.get('userId'); | ||
|
@@ -40,10 +120,6 @@ | |
if (userId && secret) { | ||
try { | ||
await sdk.forConsole.account.updateVerification({ userId, secret }); | ||
addNotification({ | ||
message: 'Email verified successfully', | ||
type: 'success' | ||
}); | ||
await Promise.all([ | ||
invalidate(Dependencies.ACCOUNT), | ||
invalidate(Dependencies.FACTORS) | ||
|
@@ -59,21 +135,69 @@ | |
|
||
onMount(() => { | ||
updateEmailVerification(); | ||
restoreTimerState(); | ||
}); | ||
|
||
onDestroy(() => { | ||
if (timerInterval) { | ||
clearInterval(timerInterval); | ||
} | ||
// round up localstorage when component is destroyed | ||
if (browser) { | ||
localStorage.removeItem(TIMER_END_KEY); | ||
localStorage.removeItem(EMAIL_SENT_KEY); | ||
} | ||
}); | ||
</script> | ||
|
||
<Modal bind:show title="Send verification email" {onSubmit}> | ||
<Card.Base variant="secondary" padding="s"> | ||
<Layout.Stack gap="m"> | ||
<Typography.Text gap="m"> | ||
To continue using Appwrite Cloud, please verify your email address. An email will be | ||
sent to <Typography.Text variant="m-600" style="display: inline;" | ||
>{get(user)?.email}</Typography.Text> | ||
</Typography.Text> | ||
</Layout.Stack> | ||
</Card.Base> | ||
|
||
<svelte:fragment slot="footer"> | ||
<Button submit disabled={creating}>{emailSent ? 'Resend email' : 'Send email'}</Button> | ||
</svelte:fragment> | ||
</Modal> | ||
{#if shouldShowModal || show} | ||
<div class="email-verification-scrim"> | ||
<Modal | ||
show={true} | ||
title="Verify your email address" | ||
{onSubmit} | ||
dismissible={false} | ||
autoClose={false}> | ||
<Card.Base variant="secondary" padding="s"> | ||
<Layout.Stack gap="s"> | ||
<Typography.Text gap="m"> | ||
To continue using Appwrite Cloud, please verify your email address. An email | ||
will be sent to <Typography.Text | ||
variant="m-600" | ||
color="neutral-secondary" | ||
style="display: inline;">{get(user)?.email}</Typography.Text> | ||
</Typography.Text> | ||
<Layout.Stack direction="row" gap="xxs"> | ||
<Link variant="default" on:click={() => logout(false)}>Switch account</Link> | ||
</Layout.Stack> | ||
{#if emailSent && resendTimer > 0} | ||
<Typography.Text color="neutral-secondary"> | ||
Didn't get the email? Try again in {resendTimer}s | ||
</Typography.Text> | ||
{/if} | ||
</Layout.Stack> | ||
</Card.Base> | ||
|
||
<svelte:fragment slot="footer"> | ||
<Button on:click={sendVerificationEmail} disabled={creating || resendTimer > 0}> | ||
{emailSent ? 'Resend email' : 'Send email'} | ||
</Button> | ||
Comment on lines
+182
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not button submit and use the |
||
</svelte:fragment> | ||
</Modal> | ||
</div> | ||
{/if} | ||
|
||
<style> | ||
.email-verification-scrim { | ||
position: fixed; | ||
top: 0; | ||
left: 0; | ||
width: 100%; | ||
height: 100%; | ||
background-color: hsl(240 5% 8% / 0.6); | ||
backdrop-filter: blur(4px); | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
} | ||
</style> |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -45,7 +45,8 @@ | |
import { headerAlert } from '$lib/stores/headerAlert'; | ||
import { UsageRates } from '$lib/components/billing'; | ||
import { canSeeProjects } from '$lib/stores/roles'; | ||
import { BottomModalAlert, EmailVerificationBanner } from '$lib/components'; | ||
import { BottomModalAlert } from '$lib/components'; | ||
import SendVerificationEmailModal from '$lib/components/account/sendVerificationEmailModal.svelte'; | ||
Comment on lines
+48
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate verification modal renders on /console/verify-email This layout always renders SendVerificationEmailModal, and the verify-email page also renders it. Results: double scrims, duplicate timers/listeners. Apply this diff to suppress the layout modal on the verify-email route: <BottomModalAlert />
-<SendVerificationEmailModal />
+{#if !page.url.pathname.includes('/console/verify-email')}
+ <SendVerificationEmailModal />
+{/if} Also applies to: 370-371 🤖 Prompt for AI Agents
|
||
import { | ||
IconAnnotation, | ||
IconBookOpen, | ||
|
@@ -337,17 +338,18 @@ | |
!page?.params.organization && | ||
!page.url.pathname.includes('/console/account') && | ||
!page.url.pathname.includes('/console/card') && | ||
!page.url.pathname.includes('/console/onboarding')} | ||
showHeader={!page.url.pathname.includes('/console/onboarding/create-project')} | ||
showFooter={!page.url.pathname.includes('/console/onboarding/create-project')} | ||
!page.url.pathname.includes('/console/onboarding') && | ||
!page.url.pathname.includes('/console/verify-email')} | ||
showHeader={!page.url.pathname.includes('/console/onboarding/create-project') && | ||
!page.url.pathname.includes('/console/verify-email')} | ||
showFooter={!page.url.pathname.includes('/console/onboarding/create-project') && | ||
!page.url.pathname.includes('/console/verify-email')} | ||
selectedProject={page.data?.project}> | ||
<!-- <Header slot="header" />--> | ||
<slot /> | ||
<Footer slot="footer" /> | ||
</Shell> | ||
|
||
<EmailVerificationBanner /> | ||
|
||
{#if $wizard.show && $wizard.component} | ||
<svelte:component this={$wizard.component} {...$wizard.props} /> | ||
{:else if $wizard.cover} | ||
|
@@ -365,3 +367,4 @@ | |
{/if} | ||
|
||
<BottomModalAlert /> | ||
<SendVerificationEmailModal /> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<script lang="ts"> | ||
// verify email layout | ||
</script> | ||
|
||
<slot /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't clear localStorage on destroy; it defeats persistence of the resend timer.
Clearing
TIMER_END_KEY
/EMAIL_SENT_KEY
inonDestroy
breaks the “persist across reloads” goal and allows immediate resends after a refresh/navigation.Apply this diff:
📝 Committable suggestion
🤖 Prompt for AI Agents