Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
185 changes: 155 additions & 30 deletions src/lib/components/account/sendVerificationEmailModal.svelte
Original file line number Diff line number Diff line change
@@ -1,32 +1,110 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { invalidate, goto } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
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 Link from '$lib/elements/link.svelte';
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Dependencies } from '$lib/constants';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { base } from '$app/paths';
import { browser } from '$app/environment';
import { addNotification } from '$lib/stores/notifications';
let { show = $bindable(false) } = $props();
let { show = $bindable(false), email }: { show?: boolean; email?: string } = $props();
let creating = $state(false);
let emailSent = $state(false);
let resendTimer = $state(0);
let timerInterval: ReturnType<typeof setInterval> | null = null;
async function logout(redirect = true) {
try {
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
if (redirect) {
await invalidate(Dependencies.ACCOUNT);
goto(`${base}/login`);
}
} catch (error) {
addNotification({
type: 'error',
title: 'Logout failed',
message: 'Unable to log out. Please try again or refresh the page.'
});
}
}
let cleanUrl = $derived(page.url.origin + page.url.pathname);
const cleanUrl = $derived(page.url.origin + page.url.pathname);
// Manage resend timer in localStorage
const TIMER_END_KEY = 'email_verification_timer_end';
const EMAIL_SENT_KEY = 'email_verification_sent';
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 onSubmit() {
if (creating) return;
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();
} catch (error) {
addNotification({ message: error.message, type: 'error' });
addNotification({
type: 'error',
title: 'Failed to send verification email',
message: 'Unable to send verification email. Please try again.'
});
console.error('Failed to send verification email:', error);
} finally {
creating = false;
}
Expand All @@ -40,40 +118,87 @@
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)
]);
goto(`${base}/`);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
type: 'error',
title: 'Email verification failed',
message: 'Unable to verify your email. Please try again.'
});
console.error('Failed to verify email:', error);
}
}
}
onMount(() => {
updateEmailVerification();
restoreTimerState(); // Check for existing timer
});
onDestroy(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
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>
<div class="email-verification-scrim">
<Modal
bind:show
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;">{email || get(user)?.email}</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()}>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>

<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>
2 changes: 1 addition & 1 deletion src/lib/components/alerts/emailVerificationBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
>Verify email</Button>
</svelte:fragment>
</HeaderAlert>
<SendVerificationEmailModal bind:show={showSendVerification} />
<SendVerificationEmailModal bind:show={showSendVerification} email={$user?.email} />
{/if}
4 changes: 1 addition & 3 deletions src/routes/(console)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
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 {
IconAnnotation,
IconBookOpen,
Expand Down Expand Up @@ -346,8 +346,6 @@
<Footer slot="footer" />
</Shell>

<EmailVerificationBanner />

{#if $wizard.show && $wizard.component}
<svelte:component this={$wizard.component} {...$wizard.props} />
{:else if $wizard.cover}
Expand Down
142 changes: 142 additions & 0 deletions src/routes/(console)/verify-email/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<script lang="ts">
import Sidebar from '$lib/components/sidebar.svelte';
import Navbar from '$lib/components/navbar.svelte';
import SendVerificationEmailModal from '$lib/components/account/sendVerificationEmailModal.svelte';
import { writable } from 'svelte/store';
import { invalidate } from '$app/navigation';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { Dependencies } from '$lib/constants';
import { page } from '$app/state';
let sideBarIsOpen = writable(false);
let showAccountMenu = writable(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Copy link
Member

Choose a reason for hiding this comment

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

we don't need this if no any is used.

Copy link
Member Author

Choose a reason for hiding this comment

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

I feel using any here might be more practical than defining all the properties inline, since otherwise we’d need to add all the props explicitly. What do you think?

let project: any = { $id: 'verify-email-project', region: 'us-east-1', name: 'Verify Email Project' };

Copy link
Member

Choose a reason for hiding this comment

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

only added the dummy ones and casted to Models.Project

let project: any = {
$id: 'verify-email-project',
region: 'us-east-1',
name: 'Verify Email Project'
};
let avatar = '/images/default-avatar.png';
let progressCard = {
title: 'Get started',
percentage: 33
};
let navbarProps = {
logo: {
src: '/images/appwrite-logo-light.svg',
alt: 'Appwrite Logo'
},
avatar: avatar,
organizations: [],
currentProject: project
};
let showVerificationModal = $state(!page.data.account?.emailVerification);
$effect(() => {
if (page.data.account?.emailVerification) {
checkEmailVerification();
}
});
async function checkEmailVerification() {
if (page.data.account?.emailVerification) {
await goto(`${base}/`);
}
}
onMount(() => {
if (!page.data.account) {
goto(`${base}/login`);
return;
}
// If email is already verified, redirect immediately
if (page.data.account?.emailVerification) {
checkEmailVerification();
return;
}
const interval = setInterval(async () => {
await invalidate(Dependencies.ACCOUNT);
checkEmailVerification();
}, 2000);
return () => clearInterval(interval);
});
</script>

<svelte:head>
<title>Verify Email - Appwrite Console</title>
</svelte:head>

<div class="verify-email-page">
<Navbar
{...navbarProps}
bind:sideBarIsOpen={$sideBarIsOpen}
bind:showAccountMenu={$showAccountMenu} />

<Sidebar
bind:sideBarIsOpen={$sideBarIsOpen}
bind:showAccountMenu={$showAccountMenu}
{project}
{avatar}
{progressCard}
state="open" />

<!-- email verification modal -->
<SendVerificationEmailModal
bind:show={showVerificationModal}
email={page.data.account?.email} />
</div>

<style lang="scss">
.verify-email-page {
display: flex;
min-height: 100vh;
position: relative;
width: 100%;
margin: 0;
padding: 0;
}
.main-content {
flex: 1;
padding: 2rem;
margin-left: 190px;
min-height: 100vh;
}
:global(.verify-email-page .sidebar) {
position: fixed !important;
left: 0 !important;
top: 0 !important;
height: 100vh !important;
z-index: 1000 !important;
filter: blur(4px);
opacity: 0.6;
}
/* Blur the navbar */
:global(.verify-email-page .navbar),
:global(.verify-email-page [data-pink-navbar]),
:global(.verify-email-page header) {
filter: blur(2px);
opacity: 0.4;
z-index: 1;
}
/* ensure modal is above everything and not blurred */
:global(.verify-email-page .email-verification-scrim) {
z-index: 9999 !important;
filter: none !important;
opacity: 1 !important;
}
:global(.verify-email-page .email-verification-scrim *) {
filter: none !important;
opacity: 1 !important;
}
</style>
Loading
Loading