feat: verify users email (#215)

* worked on the email verification

* worked on the email verification

* fixed the emailing issue and the supabase updating issue

* chore: add timer to resend, add supabase.server, enforce unique email

* chore: remove nodemailer logger

* remove async

* remove disposable email verification

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Em_Dev
2024-03-22 06:30:24 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 5a566cbb4f
commit bd905b580a
17 changed files with 261 additions and 38 deletions
+1
View File
@@ -155,6 +155,7 @@ This repo is a mono repo that consists of 3 projects:
```env
PUBLIC_SUPABASE_URL=<API URL>
PUBLIC_SUPABASE_ANON_KEY=<anon key>
PRIVATE_SUPABASE_SERVICE_ROLE=<service_role key>
```
- To view the Supabase studio, open the Studio URL from the result of `pnpm supabase start`
+2
View File
@@ -2,11 +2,13 @@
PUBLIC_SUPABASE_PROJECT_REF=localhost
PUBLIC_SUPABASE_URL=http://localhost:54321
PUBLIC_SUPABASE_ANON_KEY=
PRIVATE_SUPABASE_SERVICE_ROLE=
# Email setup - Optional if you want to get/send emails
SMTP_PASSWORD_NOTIFY=
SMTP_USER_NOTIFY=
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
@@ -9,6 +9,7 @@
export let maxWidth = '';
export let containerClass = '';
export let size = '';
export let isCloseable = true;
afterUpdate(() => {
if (open) {
@@ -39,9 +40,11 @@
<p class="dark:text-white text-md font-medium m-0">
{modalHeading}
</p>
<div class="rounded-full">
<CloseButton onClick={onClose} contained={true} size="small" />
</div>
{#if isCloseable}
<div class="rounded-full">
<CloseButton onClick={onClose} contained={true} size="small" />
</div>
{/if}
</div>
<div class="body p-1 h-4/5 overflow-y-auto {containerClass}">
@@ -0,0 +1,84 @@
<script>
{
}
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import { profile } from '$lib/utils/store/user';
import {
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
import { currentOrg } from '$lib/utils/store/org';
const WAIT_SEC = 120;
const WAIT_TIME = WAIT_SEC * 1000;
let loading = false;
let isSent = false;
let interval;
let countDown = WAIT_SEC;
const resendVerificationCode = async () => {
loading = true;
try {
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
to: $profile.email,
profileId: $profile.id,
orgSiteName: $currentOrg.siteName
});
isSent = true;
} catch (error) {
snackbar.error('error while sending code');
} finally {
loading = false;
}
interval = setInterval(() => {
countDown = countDown - 1;
}, 1000);
setTimeout(() => {
isSent = false;
clearInterval(interval);
}, WAIT_TIME);
};
</script>
<Modal
open={!$profile.is_email_verified}
isCloseable={false}
width="w-4/5"
maxWidth="w-[500px]"
containerClass="p-4"
>
<div class="flex flex-col items-center space-y-6 text-center">
<img src="/verify-email.svg" alt="email verification" />
<p class="font-bold text-xl">Verify your Email</p>
<p class="text-sm w-[70%] text-gray-700">
We've sent a verification email to {$profile.email} to confirm the validity of the email provided.
Kindly verify your email to continue using ClassroomIO.
</p>
<div class="flex items-center flex-col">
<PrimaryButton
isDisabled={loading || isSent}
className="font-normal"
onClick={resendVerificationCode}
>
{#if loading}
Loading...
{:else}
Resend Verification Code
{/if}
</PrimaryButton>
{#if isSent}
<p class="text-xs text-gray-700">Resend verification in {countDown} seconds</p>
{/if}
</div>
</div>
</Modal>
@@ -1,21 +1,31 @@
<script>
import Modal from '../Modal/index.svelte';
import { writable } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';
import { welcomeModalStore } from './store';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import {
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
import { profile } from '$lib/utils/store/user';
let query = new URLSearchParams($page.url.search);
let welcomePopup = query.get('welcomePopup');
const closeModal = () => {
$welcomeModalStore.open = false;
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TO_APP, {
to: $profile.email,
name: $profile.fullname
});
goto($currentOrgPath + '/courses?create=true');
};
</script>
<Modal
onClose={closeModal}
bind:open={$welcomeModalStore.open}
open={welcomePopup === 'true'}
width="w-9/12"
maxWidth="w-[800px]"
modalHeading="Welcome"
>
<p class="text-black dark:text-white text-sm md:text-base lg:text-lg">
@@ -0,0 +1,24 @@
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { PRIVATE_SUPABASE_SERVICE_ROLE } from '$env/static/private';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
export let supabase: SupabaseClient;
/**
* Should only be called on server files
*
* @returns supabase
*/
export const getServerSupabase = () => {
if (supabase) return supabase;
supabase = createClient(PUBLIC_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false
}
});
return supabase;
};
@@ -2,6 +2,7 @@ import { supabase } from '$lib/utils/functions/supabase';
export const NOTIFICATION_NAME = {
WELCOME_TO_APP: 'WELCOME TO APP',
VERIFY_EMAIL: 'VERFIY_EMAIL',
INVITE_TEACHER: 'INVITE TEACHER',
WELCOME_TEACHER_TO_COURSE: 'WELCOME TEACHER TO COURSE',
SEND_TEACHER_STUDENT_BUY_REQUEST: 'SEND TEACHER STUDENT BUY REQUEST',
@@ -15,6 +16,7 @@ export const NOTIFICATION_NAME = {
const NAME_TO_PATH = {
[NOTIFICATION_NAME.WELCOME_TO_APP]: '/api/email/welcome',
[NOTIFICATION_NAME.VERIFY_EMAIL]: '/api/email/verify_email',
[NOTIFICATION_NAME.INVITE_TEACHER]: '/api/email/invite',
[NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE]: '/api/email/course/teacher_welcome',
[NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST]:
@@ -1,6 +1,7 @@
import nodemailer from 'nodemailer';
import {
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASSWORD,
SMTP_USER_NOTIFY,
@@ -24,7 +25,7 @@ export const getTransporter = async (
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: 465,
port: parseInt(SMTP_PORT),
secure: true,
auth: {
user: isPersonal ? SMTP_USER : SMTP_USER_NOTIFY,
+5 -1
View File
@@ -21,6 +21,8 @@ interface ProfileStore {
goal: string;
source: string;
telegram_chat_id: number | null;
is_email_verified: boolean;
verified_at: string;
}
export const defaultUserState: UserStore = {
@@ -41,7 +43,9 @@ export const defaultProfileState: ProfileStore = {
role: '',
goal: '',
source: '',
telegram_chat_id: null
telegram_chat_id: null,
is_email_verified: false,
verified_at: ''
};
export const user = writable<UserStore>(defaultUserState);
+3 -2
View File
@@ -22,7 +22,7 @@
import showAppsSideBar from '$lib/utils/functions/showAppsSideBar';
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
import { user, profile } from '$lib/utils/store/user';
import { getSupabase, isSupabaseTokenInLocalStorage } from '$lib/utils/functions/supabase';
import { getSupabase } from '$lib/utils/functions/supabase';
import { isMobile } from '$lib/utils/store/useMobile';
import { ROUTE } from '$lib/utils/constants/routes';
import { getOrganizations } from '$lib/utils/services/org';
@@ -41,6 +41,7 @@
let supabase = getSupabase();
let path = $page.url?.pathname?.replace('/', '');
let queryParam = $page.url?.search;
let carbonTheme: CarbonTheme = 'white';
const delayedPreloading = derived(navigating, (currentPreloading, set) => {
@@ -82,7 +83,7 @@
console.log('Get user', authUser);
if (!authUser && !isPublicRoute($page.url?.pathname)) {
return goto('/login?redirect=/' + path);
return goto('/login?redirect=/' + path + queryParam);
}
// Skip refetching profile, if already in store
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import sendEmail from '$defer/sendEmail';
const supabase = getSupabase();
export async function POST({ request }) {
const { to, profileId, orgSiteName } = await request.json();
const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/verify_email', to, profileId);
if (!to || !profileId || !accessToken) {
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const origin = request.headers.get('origin');
const verificationData = JSON.stringify({
profileId,
orgSiteName
});
const verificationLink = `${origin}/api/verify?data=${encodeURIComponent(
btoa(verificationData)
)}`;
await sendEmail({
to,
subject: '[ClassroomIO]: Verify your email',
content: `
<p>Thank you for signing up</p>
<p>To verify your email, please click the <strong>Verify</strong> button below /p>
<div>
<a class="button" href="${verificationLink}">Verify</a>
</div>
`,
isPersonalEmail: true
});
return json({
success: true,
message: `Email sent ${verificationLink}`
});
}
@@ -0,0 +1,29 @@
import { redirect, json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export const GET = async ({ url }) => {
const supabase = getServerSupabase();
const data = url.searchParams.get('data');
if (!data) return json({ message: 'data is not found' }, { status: 404 });
const hashData = decodeURIComponent(data);
console.log('hashData', hashData);
const decodedData = atob(hashData);
console.log('decodedData', decodedData);
const { profileId, orgSiteName } = JSON.parse(decodedData);
const { error } = await supabase
.from('profile')
.update({ is_email_verified: true, verified_at: new Date().toDateString() })
.match({ id: profileId });
if (error) {
console.log('profile not found', error);
throw redirect(307, '/404');
}
throw redirect(307, `/org/${orgSiteName}?welcomePopup=true`);
};
@@ -9,7 +9,6 @@
import { onboardingValidation } from '$lib/utils/functions/validator';
import { supabase } from '$lib/utils/functions/supabase';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { welcomeModalStore } from '$lib/components/WelcomeModal/store';
import { getOrganizations } from '$lib/utils/services/org';
import { generateSitename } from '$lib/utils/functions/org';
import {
@@ -181,16 +180,13 @@
if (fields.fullname) {
$profile.fullname = fields.fullname;
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TO_APP, {
to: $profile.email,
name: fields.fullname
});
}
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
to: $profile.email,
profileId: $profile.id,
orgSiteName: fields.siteName
});
console.log('data', data);
console.log('error', error);
$welcomeModalStore.open = true;
return goto(`/org/${fields.siteName}`);
}
@@ -3,10 +3,11 @@
import AddOrgModal from '$lib/components/Org/AddOrgModal/AddOrgModal.svelte';
import { isQuizPage } from '$lib/utils/functions/app';
import OrgSideBar from '$lib/components/Org/SideBar.svelte';
import VerifyEmailModal from '$lib/components/Org/VerifyEmail/VerifyEmailModal.svelte';
</script>
<AddOrgModal />
<VerifyEmailModal />
<div class="org-root w-full flex items-center justify-between">
{#if !isQuizPage($page.url?.pathname)}
<OrgSideBar />
@@ -14,7 +14,6 @@
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { snackbar } from '$lib/components/Snackbar/store';
let supabase = getSupabase();
let fields = Object.assign({}, SIGNUP_FIELDS);
@@ -44,21 +43,6 @@
try {
loading = true;
// Check if the email address is disposable
const emailCheck = await fetch('https://is-disposable-email.ephraimduncan68.workers.dev/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email: fields.email })
});
const emailCheckResponse = await emailCheck.json();
if (emailCheckResponse.isDisposable === true) {
snackbar.error('The email you provided is disposable');
return;
}
const {
data: { session },
error
+20
View File
@@ -0,0 +1,20 @@
<svg width="156" height="125" viewBox="0 0 156 125" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4656_21338)">
<path d="M77.8191 124.4C120.797 124.4 155.638 122.358 155.638 119.839C155.638 117.32 120.797 115.277 77.8191 115.277C34.8408 115.277 0 117.32 0 119.839C0 122.358 34.8408 124.4 77.8191 124.4Z" fill="#F5F5F5"/>
<path d="M130.206 37.3201V90.3975C130.206 93.5766 127.58 96.341 124.262 96.341H33.4504C30.2713 96.341 27.5068 93.7148 27.5068 90.3975V37.3201C27.5068 36.4908 27.6451 35.7997 27.9215 35.1086C28.7508 32.897 30.9624 31.3766 33.4504 31.3766H124.262C126.75 31.3766 128.824 32.897 129.791 35.1086C130.068 35.7997 130.206 36.4908 130.206 37.3201Z" fill="#0233BD"/>
<path opacity="0.3" d="M129.791 35.1086L81.2747 75.3312C79.8924 76.437 77.8191 76.437 76.4369 75.3312L27.9209 35.1086C28.7502 32.897 30.9618 31.3766 33.4498 31.3766H124.262C126.75 31.3766 128.823 32.897 129.791 35.1086Z" fill="black"/>
<path d="M94.5442 3.04102H46.3047V31.3766V50.313L76.4371 75.3312C77.8194 76.437 79.8927 76.437 81.2749 75.3312L111.407 50.313V31.3766V13.6841L94.5442 3.04102Z" fill="white"/>
<path d="M78.9248 77.1281C77.8191 77.1281 76.8515 76.7134 76.0222 76.0223L45.8897 51.0041C45.7515 50.8659 45.6133 50.5895 45.6133 50.313V3.041C45.6133 2.62634 46.0279 2.21167 46.4426 2.21167H94.6822C94.8204 2.21167 94.9586 2.21167 95.0968 2.34989L111.822 12.8548C112.098 12.993 112.236 13.2694 112.236 13.5459V50.313C112.236 50.5895 112.098 50.8659 111.96 51.0041L81.8275 76.0223C80.9982 76.7134 79.8924 77.1281 78.9248 77.1281ZM47.2719 50.0366L76.9897 74.6401C78.0955 75.6077 79.616 75.6077 80.7217 74.6401L110.578 50.0366V14.0988L94.4057 3.87034H47.2719V50.0366Z" fill="#EDEDED"/>
<path d="M111.406 13.6841L94.543 3.04102L100.072 17.0015L111.406 13.6841Z" fill="#EBEBEB"/>
<path d="M100.072 17.8308C99.7956 17.8308 99.381 17.6926 99.2427 17.2779L93.8521 3.31747C93.7139 3.04102 93.8521 2.62636 94.1285 2.34991C94.405 2.07347 94.8196 2.07347 95.0961 2.34991L111.821 12.8548C112.097 12.993 112.236 13.4077 112.236 13.6841C112.236 13.9606 111.959 14.237 111.683 14.3752L100.349 17.8308C100.21 17.8308 100.072 17.8308 100.072 17.8308ZM96.3401 5.11436L100.625 16.0339L109.471 13.4077L96.3401 5.11436Z" fill="#EAEAEA"/>
<path d="M92.0565 47.8249H95.9268C94.5446 50.3129 92.6094 52.1098 89.9832 53.492C87.0805 55.0124 83.4868 55.8418 79.4783 55.8418C75.4699 55.8418 72.1525 55.1507 69.3881 53.9067C66.6237 52.5244 64.6886 50.5893 63.4446 47.9631C62.2006 45.3369 61.7859 42.5724 62.0623 39.5315C62.3388 36.2142 63.3063 33.0351 65.2414 30.1324C67.1765 27.2298 69.3881 25.0182 72.2908 23.636C75.1934 22.1156 78.3725 21.4244 81.9663 21.4244C85.0072 21.4244 87.6334 21.9773 89.845 23.2213C92.0566 24.3271 93.7152 26.124 94.821 28.1973C95.9268 30.4089 96.3414 32.7587 96.065 35.2467C95.7886 38.2875 94.6828 41.052 92.6094 43.54C89.9832 46.5809 86.8041 48.2396 83.2103 48.2396C82.2428 48.2396 81.5517 48.1013 80.9988 47.6867C80.5841 47.272 80.1694 46.8573 80.1694 46.1662C78.649 47.5484 76.9903 48.2396 75.1934 48.2396C73.2583 48.2396 71.5997 47.5484 70.3557 46.1662C69.1117 44.784 68.697 42.9871 68.8352 40.6373C69.1117 37.8729 70.0792 35.2467 71.7379 32.8969C73.8112 30.1324 76.4374 28.612 79.4783 28.612C81.5517 28.612 83.0721 29.4413 84.0397 31.1L84.5926 29.1649H89.2921L85.5601 42.0195C85.2837 42.8489 85.1454 43.4018 85.1454 43.54C85.1454 43.8164 85.1454 44.0929 85.2837 44.2311C85.4219 44.3693 85.5601 44.5075 85.6983 44.5075C86.2512 44.5075 87.0806 44.0929 88.0481 43.4018C89.2921 42.4342 90.3979 41.3284 91.2272 39.808C92.0566 38.2875 92.6094 36.6289 92.7477 35.1084C93.0241 32.2058 92.0565 29.7178 90.1214 27.7827C88.1863 25.8475 85.2837 24.88 81.4134 24.88C78.2343 24.88 75.3317 25.5711 72.9819 26.8151C70.6321 28.1973 68.8352 29.9942 67.453 32.344C66.0708 34.6938 65.3797 37.1818 65.1032 39.808C64.965 42.296 65.3797 44.6458 66.4854 46.7191C67.5912 48.7924 69.2499 50.3129 71.4614 51.2804C73.673 52.248 76.2992 52.6627 79.2019 52.6627C82.1045 52.6627 84.5926 52.248 86.6659 51.4187C88.8774 50.5893 90.6743 49.4835 92.0565 47.8249ZM73.2583 40.4991C73.1201 42.0195 73.3965 43.1253 73.9494 43.8164C74.5023 44.5075 75.1934 44.9222 76.161 44.9222C76.8521 44.9222 77.5432 44.784 78.0961 44.3693C78.5108 44.0929 79.0637 43.8164 79.4783 43.2635C80.1694 42.5724 80.8606 41.4667 81.4134 40.0844C81.9663 38.7022 82.381 37.32 82.381 36.2142C82.5192 34.832 82.2428 33.8644 81.6899 33.0351C81.137 32.2058 80.3077 31.9293 79.3401 31.9293C78.3726 31.9293 77.2668 32.344 76.4374 33.1733C75.4699 34.0027 74.7788 35.1084 74.2259 36.6289C73.673 38.0111 73.2583 39.3933 73.2583 40.4991Z" fill="#407BFF"/>
<path d="M130 45C136.075 45 141 40.0751 141 34C141 27.9249 136.075 23 130 23C123.925 23 119 27.9249 119 34C119 40.0751 123.925 45 130 45Z" fill="#F15A24"/>
<path d="M28.4739 7.04933C25.1566 7.87867 22.6686 11.6107 22.6686 16.0338C22.6686 11.4724 20.1806 7.74044 16.8633 7.04933C20.1806 6.49644 22.6686 3.59378 22.6686 0C22.6686 3.45556 25.1566 6.35822 28.4739 7.04933Z" fill="#407BFF"/>
<path d="M154.533 76.0222C151.215 76.8516 148.727 80.5836 148.727 85.0067C148.727 80.4453 146.239 76.7133 142.922 76.0222C146.239 75.4693 148.727 72.5667 148.727 68.9729C148.727 72.5667 151.215 75.4693 154.533 76.0222Z" fill="#407BFF"/>
</g>
<defs>
<clipPath id="clip0_4656_21338">
<rect width="155.5" height="124.262" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,6 @@
alter table "public"."profile" add column "is_email_verified" boolean default false;
alter table "public"."profile" add column "verified_at" timestamp with time zone;
CREATE UNIQUE INDEX profile_email_key ON public.profile USING btree (email);
alter table "public"."profile" add constraint "profile_email_key" UNIQUE using index "profile_email_key";