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:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
@@ -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);
}
}
};