Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 153 additions & 25 deletions src/lib/components/account/sendVerificationEmailModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,117 @@
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 { show = $bindable(false), email: emailFromParent = null } = $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';

Comment on lines +25 to 28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Namespace timer keys per account to avoid cross‑account bleed.

Using global keys means logging out and switching users within 60s incorrectly throttles the next user. Namespace by user.$id (fallback to email) and update all usages.

-// Timer state key for localStorage
-const TIMER_END_KEY = 'email-verification-timer-end';
-const EMAIL_SENT_KEY = 'email-verification-sent';
+// Timer state keys, namespaced per account to avoid cross-account bleed
+const storageKey = (name: string) => {
+    const u = get(user);
+    const id = u?.$id ?? u?.email ?? 'anon';
+    return `${name}:${id}`;
+};
+const TIMER_END_KEY = () => storageKey('email-verification-timer-end');
+const EMAIL_SENT_KEY = () => storageKey('email-verification-sent');
-            localStorage.setItem(TIMER_END_KEY, timerEndTime.toString());
-            localStorage.setItem(EMAIL_SENT_KEY, 'true');
+            localStorage.setItem(TIMER_END_KEY(), timerEndTime.toString());
+            localStorage.setItem(EMAIL_SENT_KEY(), 'true');
-        const savedTimerEnd = localStorage.getItem(TIMER_END_KEY);
-        const savedEmailSent = localStorage.getItem(EMAIL_SENT_KEY);
+        const savedTimerEnd = localStorage.getItem(TIMER_END_KEY());
+        const savedEmailSent = localStorage.getItem(EMAIL_SENT_KEY());
-                localStorage.removeItem(TIMER_END_KEY);
-                localStorage.removeItem(EMAIL_SENT_KEY);
+                localStorage.removeItem(TIMER_END_KEY());
+                localStorage.removeItem(EMAIL_SENT_KEY());
-                    localStorage.removeItem(TIMER_END_KEY);
-                    localStorage.removeItem(EMAIL_SENT_KEY);
+                    localStorage.removeItem(TIMER_END_KEY());
+                    localStorage.removeItem(EMAIL_SENT_KEY());

Also applies to: 46-48, 56-58, 70-71, 89-91

let cleanUrl = $derived(page.url.origin + page.url.pathname);
const resolvedEmail = $derived(emailFromParent ?? get(user)?.email);

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

async function onSubmit() {
await sendVerificationEmail();
}

async function updateEmailVerification() {
const searchParams = page.url.searchParams;
const userId = searchParams.get('userId');
Expand All @@ -40,10 +121,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)
Expand All @@ -59,21 +136,72 @@

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);
}
});
Comment on lines +142 to 151
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Don't clear localStorage on destroy; it defeats persistence of the resend timer.

Clearing TIMER_END_KEY/EMAIL_SENT_KEY in onDestroy breaks the “persist across reloads” goal and allows immediate resends after a refresh/navigation.

Apply this diff:

 onDestroy(() => {
     if (timerInterval) {
         clearInterval(timerInterval);
     }
-    // round up localstorage when component is destroyed
-    if (browser) {
-        localStorage.removeItem(TIMER_END_KEY);
-        localStorage.removeItem(EMAIL_SENT_KEY);
-    }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onDestroy(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
// round up localstorage when component is destroyed
if (browser) {
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
}
});
onDestroy(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
});
🤖 Prompt for AI Agents
In src/lib/components/account/sendVerificationEmailModal.svelte around lines 146
to 155, the onDestroy handler currently clears TIMER_END_KEY and EMAIL_SENT_KEY
from localStorage which breaks persistence across navigations; remove the
localStorage.removeItem calls from onDestroy so the resend timer state is
preserved across reloads and navigations, leaving only the
clearInterval(timerInterval) logic; ensure any clearing of those keys happens
only when the timer naturally expires or when an explicit cancel/reset action
occurs (not on component destroy).

</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="xxs">
<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;">{resolvedEmail}</Typography.Text>
</Typography.Text>
<Layout.Stack class="u-margin-block-start-4 u-margin-block-end-24">
<Layout.Stack direction="row">
<Link variant="default" on:click={() => logout(false)}
>Switch account</Link>
</Layout.Stack>
</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 submit disabled={creating || resendTimer > 0}>
{emailSent ? 'Resend email' : 'Send email'}
</Button>
</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>
Comment on lines +194 to +207
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure the scrim overlays all console content.

Add a z-index and simplify positioning; without z-index the header/other fixed elements may sit above the scrim.

 .email-verification-scrim {
     position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
+    inset: 0;
     background-color: hsl(240 5% 8% / 0.6);
     backdrop-filter: blur(4px);
     display: flex;
     align-items: center;
     justify-content: center;
+    z-index: var(--z-modal, 1000);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
<style>
.email-verification-scrim {
position: fixed;
inset: 0;
background-color: hsl(240 5% 8% / 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal, 1000);
}
</style>
🤖 Prompt for AI Agents
In src/lib/components/account/sendVerificationEmailModal.svelte around lines 193
to 206, the scrim CSS can be overlaid by other fixed elements because it lacks a
z-index and uses verbose positioning; add a high z-index (e.g. 9999) to ensure
it sits above all console content and simplify positioning by replacing
top/left/width/height with inset: 0 while keeping position: fixed and centering
styles — update the .email-verification-scrim rule to include position: fixed;
inset: 0; z-index: 9999; display:flex; align-items:center;
justify-content:center; and keep the background and backdrop-filter as-is.

36 changes: 0 additions & 36 deletions src/lib/components/alerts/emailVerificationBanner.svelte

This file was deleted.

1 change: 0 additions & 1 deletion src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,4 @@ export { default as ViewToggle } from './viewToggle.svelte';
export { default as RegionEndpoint } from './regionEndpoint.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
17 changes: 11 additions & 6 deletions src/routes/(console)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import {
IconAnnotation,
IconBookOpen,
Expand Down Expand Up @@ -337,17 +338,18 @@
!page?.params.organization &&
!page.url.pathname.includes(base + '/account') &&
!page.url.pathname.includes(base + '/card') &&
!page.url.pathname.includes(base + '/onboarding')}
showHeader={!page.url.pathname.includes(base + '/onboarding/create-project')}
showFooter={!page.url.pathname.includes(base + '/onboarding/create-project')}
!page.url.pathname.includes(base + '/onboarding') &&
!page.url.pathname.includes(base + '/verify-email')}
showHeader={!page.url.pathname.includes(base + '/onboarding/create-project') &&
!page.url.pathname.includes(base + '/verify-email')}
showFooter={!page.url.pathname.includes(base + '/onboarding/create-project') &&
!page.url.pathname.includes(base + '/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}
Expand All @@ -365,3 +367,6 @@
{/if}

<BottomModalAlert />
{#if !page.url.pathname.includes('/console/verify-email')}
<SendVerificationEmailModal />
{/if}
10 changes: 8 additions & 2 deletions src/routes/(console)/+layout.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { LayoutLoad } from './$types';
import type { Tier } from '$lib/stores/billing';
import type { Plan, PlanList } from '$lib/sdk/billing';
import { Query } from '@appwrite.io/console';

export const load: LayoutLoad = async ({ depends, parent }) => {
const { organizations } = await parent();
export const load: LayoutLoad = async ({ depends, parent, url }) => {
const { organizations, account } = await parent();

if (isCloud && !account.emailVerification && !url.pathname.includes('/verify-email')) {
redirect(303, `${base}/verify-email${url.search}`);
}

depends(Dependencies.RUNTIMES);
depends(Dependencies.CONSOLE_VARIABLES);
Expand Down
1 change: 1 addition & 0 deletions src/routes/(console)/onboarding/create-project/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const load: PageLoad = async ({ parent }) => {
}
} catch (e) {
trackError(e, Submit.OrganizationCreate);
redirect(303, `${base}/create-organization`);
}
} else if (organizations?.total === 1) {
const org = organizations.teams[0];
Expand Down
5 changes: 5 additions & 0 deletions src/routes/(console)/verify-email/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
// verify email layout
</script>

<slot />
Loading
Loading