diff --git a/README.md b/README.md index bbdff0b0..a1aad74d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ This repo is a mono repo that consists of 3 projects: ```env PUBLIC_SUPABASE_URL= PUBLIC_SUPABASE_ANON_KEY= + PRIVATE_SUPABASE_SERVICE_ROLE= ``` - To view the Supabase studio, open the Studio URL from the result of `pnpm supabase start` diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 6d62a749..aad79fd2 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -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= diff --git a/apps/dashboard/src/lib/components/Modal/index.svelte b/apps/dashboard/src/lib/components/Modal/index.svelte index 91274b68..979704a2 100644 --- a/apps/dashboard/src/lib/components/Modal/index.svelte +++ b/apps/dashboard/src/lib/components/Modal/index.svelte @@ -9,6 +9,7 @@ export let maxWidth = ''; export let containerClass = ''; export let size = ''; + export let isCloseable = true; afterUpdate(() => { if (open) { @@ -39,9 +40,11 @@

{modalHeading}

-
- -
+ {#if isCloseable} +
+ +
+ {/if}
diff --git a/apps/dashboard/src/lib/components/Org/VerifyEmail/VerifyEmailModal.svelte b/apps/dashboard/src/lib/components/Org/VerifyEmail/VerifyEmailModal.svelte new file mode 100644 index 00000000..15c6c781 --- /dev/null +++ b/apps/dashboard/src/lib/components/Org/VerifyEmail/VerifyEmailModal.svelte @@ -0,0 +1,84 @@ + + + +
+ email verification +

Verify your Email

+

+ 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. +

+ +
+ + {#if loading} + Loading... + {:else} + Resend Verification Code + {/if} + + {#if isSent} +

Resend verification in {countDown} seconds

+ {/if} +
+
+
diff --git a/apps/dashboard/src/lib/components/WelcomeModal/WelcomeModal.svelte b/apps/dashboard/src/lib/components/WelcomeModal/WelcomeModal.svelte index 7448d5a5..346ccdfa 100644 --- a/apps/dashboard/src/lib/components/WelcomeModal/WelcomeModal.svelte +++ b/apps/dashboard/src/lib/components/WelcomeModal/WelcomeModal.svelte @@ -1,21 +1,31 @@

diff --git a/apps/dashboard/src/lib/utils/functions/supabase.server.ts b/apps/dashboard/src/lib/utils/functions/supabase.server.ts new file mode 100644 index 00000000..1572818f --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/supabase.server.ts @@ -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; +}; diff --git a/apps/dashboard/src/lib/utils/services/notification/notification.ts b/apps/dashboard/src/lib/utils/services/notification/notification.ts index bd756da8..74b63c84 100644 --- a/apps/dashboard/src/lib/utils/services/notification/notification.ts +++ b/apps/dashboard/src/lib/utils/services/notification/notification.ts @@ -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]: diff --git a/apps/dashboard/src/lib/utils/services/notification/send.ts b/apps/dashboard/src/lib/utils/services/notification/send.ts index 69b59cf6..ed43c062 100644 --- a/apps/dashboard/src/lib/utils/services/notification/send.ts +++ b/apps/dashboard/src/lib/utils/services/notification/send.ts @@ -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, diff --git a/apps/dashboard/src/lib/utils/store/user.ts b/apps/dashboard/src/lib/utils/store/user.ts index 5b0f87dd..a0a68c1a 100644 --- a/apps/dashboard/src/lib/utils/store/user.ts +++ b/apps/dashboard/src/lib/utils/store/user.ts @@ -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(defaultUserState); diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 14bbe3b5..aca971c9 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -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 diff --git a/apps/dashboard/src/routes/api/email/verify_email/+server.js b/apps/dashboard/src/routes/api/email/verify_email/+server.js new file mode 100644 index 00000000..e2c6839e --- /dev/null +++ b/apps/dashboard/src/routes/api/email/verify_email/+server.js @@ -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: ` +

Thank you for signing up

+

To verify your email, please click the Verify button below /p> +

+ Verify +
+ `, + isPersonalEmail: true + }); + + return json({ + success: true, + message: `Email sent ${verificationLink}` + }); +} diff --git a/apps/dashboard/src/routes/api/verify/+server.js b/apps/dashboard/src/routes/api/verify/+server.js new file mode 100644 index 00000000..ec008018 --- /dev/null +++ b/apps/dashboard/src/routes/api/verify/+server.js @@ -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`); +}; diff --git a/apps/dashboard/src/routes/onboarding/+page.svelte b/apps/dashboard/src/routes/onboarding/+page.svelte index 268a6933..9ae7cb2b 100644 --- a/apps/dashboard/src/routes/onboarding/+page.svelte +++ b/apps/dashboard/src/routes/onboarding/+page.svelte @@ -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}`); } diff --git a/apps/dashboard/src/routes/org/[slug]/+layout.svelte b/apps/dashboard/src/routes/org/[slug]/+layout.svelte index 94c0a216..28f2c4d7 100644 --- a/apps/dashboard/src/routes/org/[slug]/+layout.svelte +++ b/apps/dashboard/src/routes/org/[slug]/+layout.svelte @@ -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'; - +
{#if !isQuizPage($page.url?.pathname)} diff --git a/apps/dashboard/src/routes/signup/+page.svelte b/apps/dashboard/src/routes/signup/+page.svelte index 83de2d5c..4f039aac 100644 --- a/apps/dashboard/src/routes/signup/+page.svelte +++ b/apps/dashboard/src/routes/signup/+page.svelte @@ -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 diff --git a/apps/dashboard/static/verify-email.svg b/apps/dashboard/static/verify-email.svg new file mode 100644 index 00000000..db704011 --- /dev/null +++ b/apps/dashboard/static/verify-email.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/supabase/migrations/20240320081809_email_verification.sql b/supabase/migrations/20240320081809_email_verification.sql new file mode 100644 index 00000000..2acd4415 --- /dev/null +++ b/supabase/migrations/20240320081809_email_verification.sql @@ -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"; \ No newline at end of file