Skip to content
Draft
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
108 changes: 14 additions & 94 deletions src/lib/components/account/sendVerificationEmailModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@
import Link from '$lib/elements/link.svelte';
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Dependencies } from '$lib/constants';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import { slide } from 'svelte/transition';
import ResendCooldown from '$lib/components/resendCooldown.svelte';

let {
show = $bindable(false),
Expand All @@ -25,8 +23,6 @@
let error = $state(null);
let creating = $state(false);
let emailSent = $state(false);
let resendTimer = $state(0);
let timerInterval: ReturnType<typeof setInterval> | null = null;

async function logout() {
error = null;
Expand All @@ -41,91 +37,21 @@

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);
}
// Timer UI handled by ResendCooldown component

async function onSubmit() {
if (creating || resendTimer > 0) return;
if (creating) return;
error = null;
creating = true;
try {
await sdk.forConsole.account.createVerification({ url: cleanUrl });
emailSent = true;
startResendTimer();
} catch (err) {
error = err.message;
} finally {
creating = false;
}
}

onMount(restoreTimerState);

onDestroy(() => {
if (timerInterval) {
clearInterval(timerInterval);
}

if (browser) {
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
}
});
</script>

<div class="email-verification-scrim">
Expand All @@ -147,27 +73,21 @@
</Typography.Text>

<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>
{#if emailSent}
<ResendCooldown
storageKey="email_verification_resend"
seconds={60}
bind:disabled={creating}
onResend={onSubmit} />
{:else}
<Button submit submissionLoader forceShowLoader={creating} disabled={creating}>
Send email
</Button>
{/if}
</svelte:fragment>
</Modal>
</div>
Expand Down
83 changes: 83 additions & 0 deletions src/lib/components/resendCooldown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
import { Link } from '@appwrite.io/pink-svelte';

let {
storageKey,
seconds = 60,
disabled = $bindable(false),
onResend
}: {
storageKey: string;
seconds?: number;
disabled?: boolean;
onResend: () => Promise<void> | void;
} = $props();

let remaining = $state(0);
let interval: ReturnType<typeof setInterval> | null = null;

function start(now = Date.now()) {
const end = now + seconds * 1000;
if (browser) {
localStorage.setItem(`${storageKey}:end`, String(end));
}
tick(end);
startTick(end);
}

function restore() {
if (!browser) return;
const raw = localStorage.getItem(`${storageKey}:end`);
if (!raw) return;
const end = parseInt(raw);
const now = Date.now();
const rem = Math.max(0, Math.ceil((end - now) / 1000));
if (rem > 0) {
remaining = rem;
startTick(end);
} else {
localStorage.removeItem(`${storageKey}:end`);
remaining = 0;
}
}

function tick(end: number) {
const now = Date.now();
remaining = Math.max(0, Math.ceil((end - now) / 1000));
if (remaining === 0 && browser) {
localStorage.removeItem(`${storageKey}:end`);
}
}

function startTick(end: number) {
clearTick();
interval = setInterval(() => {
tick(end);
if (remaining === 0) clearTick();
}, 1000);
}

function clearTick() {
if (interval) {
clearInterval(interval);
interval = null;
}
}

async function handleResend() {
if (disabled || remaining > 0) return;
await onResend?.();
start();
}

onMount(restore);
onDestroy(() => clearTick());
</script>

{#if remaining > 0}
Try again in {remaining}s
{:else}
<Link.Button on:click={handleResend} {disabled}>Resend code</Link.Button>
{/if}
40 changes: 35 additions & 5 deletions src/routes/(public)/(guest)/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,35 @@
import { Layout } from '@appwrite.io/pink-svelte';

let mail: string, pass: string, disabled: boolean;
let showPasswordLogin: boolean = false;

export let data;

$: showPasswordLogin = pass && pass.length > 0;

async function sendSignInCode() {
try {
disabled = true;
// use createEmailToken for sign in with code
const sessionToken = await sdk.forConsole.account.createEmailToken({
userId: 'unique',
email: mail
});

const params = new URLSearchParams(window.location.search);
params.set('email', mail);
params.set('userId', sessionToken.userId);

await goto(`${base}/login/email-otp?${params.toString()}`);
} catch (error) {
disabled = false;
addNotification({
type: 'error',
message: error.message
});
}
}

async function login() {
try {
disabled = true;
Expand Down Expand Up @@ -52,7 +78,6 @@
return;
}

// no specific redirect, so redirect will happen through invalidating the account
await invalidate(Dependencies.ACCOUNT);
} catch (error) {
disabled = false;
Expand Down Expand Up @@ -92,22 +117,27 @@
<Unauthenticated coupon={data?.couponData} campaign={data?.campaign}>
<svelte:fragment slot="title">Sign in</svelte:fragment>
<svelte:fragment>
<Form onSubmit={login}>
<Form onSubmit={showPasswordLogin ? login : sendSignInCode}>
<Layout.Stack>
<InputEmail
id="email"
label="Email"
placeholder="Email"
autofocus={true}
required={true}
bind:value={mail} />
<InputPassword
id="password"
label="Password"
placeholder="Password"
required={true}
required={false}
bind:value={pass} />
<Button fullWidth submit {disabled}>Sign in</Button>

{#if showPasswordLogin}
<Button fullWidth submit {disabled}>Sign in</Button>
{:else}
<Button fullWidth submit {disabled}>Get sign in code</Button>
{/if}

{#if isCloud}
<span class="with-separators eyebrow-heading-3">or</span>
<Button secondary fullWidth on:click={onGithubLogin} {disabled}>
Expand Down
Loading
Loading