Skip to content
Open
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
109 changes: 16 additions & 93 deletions src/lib/components/account/sendVerificationEmailModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
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 { 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 +24,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 +38,23 @@
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);
}
});
onDestroy(() => {});
Copy link
Member

Choose a reason for hiding this comment

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

empty?

</script>

<div class="email-verification-scrim">
Expand All @@ -147,27 +76,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 = 'resend_cooldown_default',
Copy link
Member

Choose a reason for hiding this comment

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

lets not have a default, always mark as required.

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}
38 changes: 33 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,33 @@
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
});
await goto(
`${base}/login/email-otp?email=${encodeURIComponent(mail)}&userId=${sessionToken.userId}`
);
Comment on lines +34 to +35
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 | 🟠 Major

Preserve existing query params when switching to the OTP flow

Right now we build the OTP URL with only email and userId, which strips any pre-existing query params (code, campaign, redirect, etc.). The new loader in email-otp/+page.ts expects those params to rehydrate data, so dropping them prevents us from ever hitting the coupon/campaign apply-credit paths and also loses any redirect target after a successful OTP login. Please carry forward the current search params before adding email/userId.

-            await goto(
-                `${base}/login/email-otp?email=${encodeURIComponent(mail)}&userId=${sessionToken.userId}`
-            );
+            const params = new URLSearchParams(window.location.search);
+            params.set('email', mail);
+            params.set('userId', sessionToken.userId);
+
+            await goto(`${base}/login/email-otp?${params.toString()}`);
📝 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
`${base}/login/email-otp?email=${encodeURIComponent(mail)}&userId=${sessionToken.userId}`
);
const params = new URLSearchParams(window.location.search);
params.set('email', mail);
params.set('userId', sessionToken.userId);
await goto(`${base}/login/email-otp?${params.toString()}`);
🤖 Prompt for AI Agents
In src/routes/(public)/(guest)/login/+page.svelte around lines 34 to 35, the OTP
redirect builds a URL with only email and userId which drops any existing query
params (code, campaign, redirect, etc.); update the URL construction to preserve
current search params by copying the existing location.search (or current
URLSearchParams) into a new URLSearchParams instance, set/overwrite the email
and userId keys, and then append that full query string to
`${base}/login/email-otp` so all original params are carried forward along with
email and userId.

} catch (error) {
disabled = false;
addNotification({
type: 'error',
message: error.message
});
}
}
async function login() {
try {
disabled = true;
Expand Down Expand Up @@ -52,7 +76,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 +115,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