mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge pull request #2398 from appwrite/feat-SER-166-Block-For-Email-Verification
feat: implement email verification flow with route guard and modal
This commit is contained in:
@@ -1,79 +1,194 @@
|
||||
<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 { resolve } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let { show = $bindable(false) } = $props();
|
||||
let {
|
||||
show = $bindable(false),
|
||||
email
|
||||
}: {
|
||||
show?: boolean;
|
||||
email?: string;
|
||||
} = $props();
|
||||
|
||||
let error = $state(null);
|
||||
let creating = $state(false);
|
||||
let emailSent = $state(false);
|
||||
let resendTimer = $state(0);
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let cleanUrl = $derived(page.url.origin + page.url.pathname);
|
||||
async function logout() {
|
||||
error = null;
|
||||
try {
|
||||
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await goto(resolve('/login'));
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanUrl = $derived(page.url.origin + page.url.pathname);
|
||||
|
||||
// manage resend timer in localStorage
|
||||
const EMAIL_SENT_KEY = 'email_verification_sent';
|
||||
const TIMER_END_KEY = 'email_verification_timer_end';
|
||||
|
||||
function startResendTimer() {
|
||||
resendTimer = 60;
|
||||
emailSent = true;
|
||||
const timerEndTime = Date.now() + 60 * 1000;
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem(EMAIL_SENT_KEY, 'true');
|
||||
localStorage.setItem(TIMER_END_KEY, timerEndTime.toString());
|
||||
}
|
||||
|
||||
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;
|
||||
error = null;
|
||||
creating = true;
|
||||
try {
|
||||
await sdk.forConsole.account.createVerification({ url: cleanUrl });
|
||||
addNotification({ message: 'Verification email has been sent', type: 'success' });
|
||||
emailSent = true;
|
||||
show = false;
|
||||
} catch (error) {
|
||||
addNotification({ message: error.message, type: 'error' });
|
||||
startResendTimer();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmailVerification() {
|
||||
const searchParams = page.url.searchParams;
|
||||
const userId = searchParams.get('userId');
|
||||
const secret = searchParams.get('secret');
|
||||
onMount(restoreTimerState);
|
||||
|
||||
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)
|
||||
]);
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
message: error.message,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
onDestroy(() => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateEmailVerification();
|
||||
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>
|
||||
<div class="email-verification-scrim">
|
||||
<Modal
|
||||
bind:show
|
||||
bind:error
|
||||
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;">{email || get(user)?.email}</Typography.Text>
|
||||
</Typography.Text>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button submit disabled={creating}>{emailSent ? 'Resend email' : 'Send email'}</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
<Link variant="default" on:click={() => logout()}>Switch account</Link>
|
||||
|
||||
{#if emailSent && resendTimer > 0}
|
||||
<div transition:slide={{ duration: 150 }}>
|
||||
<Typography.Text
|
||||
color="neutral-secondary"
|
||||
style="margin-block-start: var(--gap-L, 16px);">
|
||||
Didn't get the email? Try again in {resendTimer}s
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
submit
|
||||
submissionLoader
|
||||
forceShowLoader={creating}
|
||||
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;
|
||||
}
|
||||
|
||||
/* avoids the background scroll and bars */
|
||||
:global(html:has(.email-verification-scrim)) {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { Typography } from '@appwrite.io/pink-svelte';
|
||||
import { user } from '$lib/stores/user';
|
||||
import SendVerificationEmailModal from '../account/sendVerificationEmailModal.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard';
|
||||
import { isCloud, VARS } from '$lib/system';
|
||||
|
||||
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 shouldShowEmailBanner = $derived(
|
||||
VARS.EMAIL_VERIFICATION &&
|
||||
isCloud &&
|
||||
hasUser &&
|
||||
needsEmailVerification &&
|
||||
notOnOnboarding &&
|
||||
notOnWizard
|
||||
);
|
||||
|
||||
let showSendVerification = $state(false);
|
||||
</script>
|
||||
|
||||
{#if shouldShowEmailBanner}
|
||||
<HeaderAlert type="warning" title="Your email address needs to be verified">
|
||||
<svelte:fragment>
|
||||
To avoid losing access to your projects, make sure <Typography.Text
|
||||
variant="m-500"
|
||||
style="display:inline">{$user.email}</Typography.Text> is valid and up to date. Email
|
||||
verification will be required soon.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button secondary size="s" on:click={() => (showSendVerification = true)}
|
||||
>Verify email</Button>
|
||||
</svelte:fragment>
|
||||
</HeaderAlert>
|
||||
<SendVerificationEmailModal bind:show={showSendVerification} />
|
||||
{/if}
|
||||
@@ -85,5 +85,5 @@ 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';
|
||||
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
|
||||
|
||||
@@ -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,
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Navbar, SendVerificationEmailModal, Sidebar } from '$lib/components';
|
||||
|
||||
let sideBarIsOpen = writable(false);
|
||||
let showAccountMenu = writable(false);
|
||||
let showVerificationModal = $state(!page.data.account?.emailVerification);
|
||||
|
||||
// fake props!
|
||||
const project = {
|
||||
region: 'fra',
|
||||
$id: 'appwrite',
|
||||
name: 'Appwrite Project'
|
||||
} as Models.Project;
|
||||
|
||||
const progressCard = { title: 'Get started', percentage: 33 };
|
||||
const navbarProps = {
|
||||
logo: {
|
||||
src: 'https://appwrite.io/images/logos/logo.svg',
|
||||
alt: 'Logo Appwrite'
|
||||
},
|
||||
avatar: undefined,
|
||||
organizations: []
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Verify Email - Appwrite</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={navbarProps.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;
|
||||
|
||||
:global {
|
||||
.sidebar {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
height: 100vh !important;
|
||||
z-index: 1000 !important;
|
||||
filter: blur(4px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.navbar,
|
||||
[data-pink-navbar],
|
||||
header {
|
||||
filter: blur(2px);
|
||||
opacity: 0.4;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.email-verification-scrim {
|
||||
z-index: 9999 !important;
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
|
||||
* {
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { VARS } from '$lib/system';
|
||||
|
||||
export const load: PageLoad = async ({ parent, depends, url }) => {
|
||||
if (!VARS.EMAIL_VERIFICATION) {
|
||||
redirect(303, resolve('/'));
|
||||
}
|
||||
|
||||
const { account } = await parent();
|
||||
depends(Dependencies.ACCOUNT);
|
||||
|
||||
const user = url.searchParams.get('userId') ?? null;
|
||||
const secret = url.searchParams.get('secret') ?? null;
|
||||
|
||||
if (account && account.emailVerification === true) {
|
||||
redirect(303, resolve('/'));
|
||||
} else if (user && secret) {
|
||||
try {
|
||||
await sdk.forConsole.account.updateVerification({
|
||||
userId: user,
|
||||
secret
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Email has been verified',
|
||||
timeout: 10000 // 10 seconds max, because if succeeded, loads takes some time!
|
||||
});
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
redirect(303, resolve('/'));
|
||||
}
|
||||
} else {
|
||||
redirect(303, resolve('/'));
|
||||
}
|
||||
};
|
||||
+11
-2
@@ -6,10 +6,10 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { redirectTo } from './store';
|
||||
import { base } from '$app/paths';
|
||||
import { base, resolve } from '$app/paths';
|
||||
import type { Account } from '$lib/stores/user';
|
||||
import type { AppwriteException } from '@appwrite.io/console';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { isCloud, VARS } from '$lib/system';
|
||||
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
|
||||
|
||||
export const ssr = false;
|
||||
@@ -29,6 +29,15 @@ export const load: LayoutLoad = async ({ depends, url, route }) => {
|
||||
}
|
||||
|
||||
if (account) {
|
||||
if (isCloud && !account.emailVerification && VARS.EMAIL_VERIFICATION) {
|
||||
const isConsoleRoute = route.id?.startsWith('/(console)');
|
||||
const isVerifyEmailPage = url.pathname === resolve('/verify-email');
|
||||
|
||||
if (isConsoleRoute && !isVerifyEmailPage) {
|
||||
redirect(303, resolve('/verify-email'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
account: account,
|
||||
organizations: !isCloud
|
||||
|
||||
Reference in New Issue
Block a user