feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import type { OnboardingField, OnboardingStep } from '../utils/types';
|
||||
import { currentOrg, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import { ONBOARDING_STEPS } from '../utils/constants';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import { onboardingValidation } from '../utils/validations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
export class OnboardingApi extends BaseApiWithErrors {
|
||||
step: OnboardingStep = $state(ONBOARDING_STEPS.ORG_SETUP);
|
||||
|
||||
async submit(data: OnboardingField) {
|
||||
if (this.step === ONBOARDING_STEPS.ORG_SETUP) {
|
||||
return this.submitOrgSetup(data);
|
||||
}
|
||||
|
||||
if (this.step === ONBOARDING_STEPS.USER_METADATA) {
|
||||
return this.submitUserMetada(data);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async submitOrgSetup(data: OnboardingField) {
|
||||
const errors = onboardingValidation(data, ONBOARDING_STEPS.ORG_SETUP);
|
||||
if (errors) {
|
||||
this.errors = errors;
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.onboarding.step1.$post>({
|
||||
requestFn: () => classroomio.onboarding.step1.$post({ json: data }),
|
||||
logContext: 'submitting organization setup',
|
||||
onSuccess: (result) => {
|
||||
const { organizations } = result.data;
|
||||
|
||||
orgs.set(organizations);
|
||||
currentOrg.set(organizations[0]);
|
||||
|
||||
this.errors = {};
|
||||
this.step = ONBOARDING_STEPS.USER_METADATA;
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('message' in result) {
|
||||
snackbar.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// map api validation back to frontend fields
|
||||
if ('error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async submitUserMetada(data: OnboardingField) {
|
||||
const errors = onboardingValidation(data, ONBOARDING_STEPS.USER_METADATA);
|
||||
if (errors) {
|
||||
this.errors = errors;
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.onboarding.step2.$post>({
|
||||
requestFn: () => classroomio.onboarding.step2.$post({ json: data }),
|
||||
logContext: 'submitting organization setup',
|
||||
onSuccess: (result) => {
|
||||
profile.set(result.data);
|
||||
handleLocaleChange(result.data.locale ?? 'en');
|
||||
|
||||
const welcomePopup = `${result.data.isEmailVerified}`;
|
||||
|
||||
return goto(resolve(`/org/${data.siteName}?welcomePopup=${welcomePopup}`, {}));
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('message' in result) {
|
||||
snackbar.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// map api validation back to frontend fields
|
||||
if ('error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override reset() {
|
||||
super.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingApi = new OnboardingApi();
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as WelcomeModal } from './welcome-modal.svelte';
|
||||
export { default as VerifyEmailModal } from './verify-email-modal.svelte';
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
import { Modal } from '$lib/components/Modal';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const WAIT_SEC = 120; // 2 minutes
|
||||
const WAIT_TIME = WAIT_SEC * 1000;
|
||||
|
||||
let loading = $state(false);
|
||||
let isSent = $state(false);
|
||||
let autoTriggered = $state(false);
|
||||
|
||||
let interval;
|
||||
let countDown = $state(WAIT_SEC);
|
||||
|
||||
const open = $derived(Boolean(!$profile.isEmailVerified && !!$profile.id && !!$currentOrg.id));
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!$profile.email) {
|
||||
snackbar.error('verify_email_modal.snackbar.error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading || isSent) return;
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const callbackURL = new URL(page.url.href);
|
||||
callbackURL.searchParams.set('trigger', 'app');
|
||||
|
||||
// Avoid showing welcome popup in lms
|
||||
if (!$globalStore.isOrgSite) {
|
||||
callbackURL.searchParams.set('welcomePopup', 'true');
|
||||
}
|
||||
|
||||
await authClient.sendVerificationEmail({
|
||||
email: $profile.email,
|
||||
callbackURL: callbackURL.toString()
|
||||
});
|
||||
|
||||
isSent = true;
|
||||
} catch (error) {
|
||||
snackbar.error('verify_email_modal.snackbar.error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
interval = setInterval(() => {
|
||||
countDown = countDown - 1;
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
isSent = false;
|
||||
|
||||
clearInterval(interval);
|
||||
}, WAIT_TIME);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!open || isSent || loading || autoTriggered) return;
|
||||
|
||||
// Only send verification code on load once
|
||||
autoTriggered = true;
|
||||
sendVerificationCode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal {open} 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="text-xl">{$t('verify_email_modal.heading')}</p>
|
||||
<p class="w-[70%] text-sm text-gray-700 dark:text-gray-200">
|
||||
{$t('verify_email_modal.sent_verification')}
|
||||
{$profile.email}
|
||||
{$t('verify_email_modal.to_confirm')}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<PrimaryButton isDisabled={loading || isSent} className="font-normal" onClick={sendVerificationCode}>
|
||||
{#if loading}
|
||||
{$t('verify_email_modal.loading')}
|
||||
{:else}
|
||||
{$t('verify_email_modal.resend')}
|
||||
{/if}
|
||||
</PrimaryButton>
|
||||
{#if isSent}
|
||||
<p class="text-xs text-gray-700">
|
||||
{$t('verify_email_modal.resend_in')}
|
||||
{countDown}
|
||||
{$t('verify_email_modal.seconds')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script>
|
||||
import { Modal } from '$lib/components/Modal';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
let query = new URLSearchParams(page.url.search);
|
||||
let welcomePopup = query.get('welcomePopup');
|
||||
let open = $derived(welcomePopup === 'true' && !!$profile.isEmailVerified);
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
const closeModal = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
await classroomio.onboarding.complete.$post({});
|
||||
|
||||
goto($currentOrgPath + '/courses?create=true');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal onClose={closeModal} isClosing={isLoading} {open} width="w-9/12" maxWidth="w-[800px]" modalHeading="Welcome">
|
||||
<p class="text-sm text-black md:text-base lg:text-lg dark:text-white">
|
||||
{$t('welcome_modal.we_at')}
|
||||
<a href="https://app.classroomio.com/" class="text-primary-700 no-underline hover:no-underline">ClassroomIO</a>
|
||||
{$t('welcome_modal.small_team')}
|
||||
<span class="text-primary-700">{$t('welcome_modal.thank_you')};</span>
|
||||
{$t('welcome_modal.deeply_appreciate')}
|
||||
</p>
|
||||
<img src="/images/welcome-img.svg" alt="A welcome banner" class="my-6 w-full" />
|
||||
</Modal>
|
||||
@@ -0,0 +1,59 @@
|
||||
export const ONBOARDING_STEPS = {
|
||||
ORG_SETUP: 1,
|
||||
USER_METADATA: 2
|
||||
} as const;
|
||||
|
||||
export const DROPDOWN_ITEMS = [
|
||||
{ id: 'de', text: 'German' },
|
||||
{ id: 'en', text: 'English' },
|
||||
{ id: 'es', text: 'Spanish' },
|
||||
{ id: 'fr', text: 'French' },
|
||||
{ id: 'hi', text: 'Hindi' },
|
||||
{ id: 'pl', text: 'Polish' },
|
||||
{ id: 'pt', text: 'Portuguese' },
|
||||
{ id: 'ru', text: 'Russian' },
|
||||
{ id: 'vi', text: 'Vietnamese' },
|
||||
{ id: 'da', text: 'Danish' }
|
||||
];
|
||||
|
||||
export const GOALS = [
|
||||
{
|
||||
label: 'onboarding.sell',
|
||||
value: 'sell-online'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.teach',
|
||||
value: 'teach-online'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.employees',
|
||||
value: 'employees'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.customers',
|
||||
value: 'customers'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.expanding',
|
||||
value: 'expanding-platform'
|
||||
}
|
||||
];
|
||||
|
||||
export const SOURCES = [
|
||||
{
|
||||
label: 'onboarding.articles',
|
||||
value: 'articles'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.search',
|
||||
value: 'search-engine'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.social',
|
||||
value: 'social-media'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.friends',
|
||||
value: 'friends-family'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ONBOARDING_STEPS } from './constants';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
export type OnboardingStep = (typeof ONBOARDING_STEPS)[keyof typeof ONBOARDING_STEPS];
|
||||
|
||||
export interface OnboardingField {
|
||||
fullname?: string;
|
||||
orgName?: string;
|
||||
siteName?: string;
|
||||
goal?: string;
|
||||
source?: string;
|
||||
locale?: TLocale;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Goal {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { OnboardingField, OnboardingStep } from './types';
|
||||
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
|
||||
|
||||
import { ONBOARDING_STEPS } from './constants';
|
||||
import { blockedSubdomain } from '@cio/utils/constants';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
export const onboardingValidation = (field: OnboardingField, step: OnboardingStep) => {
|
||||
if (step === ONBOARDING_STEPS.ORG_SETUP) {
|
||||
if (blockedSubdomain.includes(field.siteName || '')) {
|
||||
return { siteName: 'Sitename already exists.' };
|
||||
}
|
||||
|
||||
const result = ZOnboardingStep1.safeParse(field);
|
||||
if (!result.success) {
|
||||
return mapZodErrorsToTranslations(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === ONBOARDING_STEPS.USER_METADATA) {
|
||||
const result = ZOnboardingStep2.safeParse(field);
|
||||
if (!result.success) {
|
||||
return mapZodErrorsToTranslations(result.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user