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
+16 -1
View File
@@ -25,5 +25,20 @@ export function isQuizPage(path) {
}
export function isLMSPage(path) {
return /lms[\/a-z 0-9 -]*/.test(path);
return /lms[/a-z 0-9 -]*/.test(path);
}
export function isActive(pagePath: string, itemPath: string, exact: boolean = false) {
const pageLinkItems = pagePath.split('/');
const itemLinkItems = itemPath.split('/');
if (exact) {
return pagePath === itemPath;
}
if (itemLinkItems.length !== pageLinkItems.length) {
return false;
}
return pagePath.includes(itemPath);
}
@@ -1,21 +1,6 @@
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog';
import { initSentry, setSentryUser } from '$lib/utils/services/sentry';
import { profile, user } from '$lib/utils/store/user';
import { ROLE } from '$lib/utils/constants/roles';
import { ROUTE } from '$lib/utils/constants/routes';
import { dev } from '$app/environment';
import { get } from 'svelte/store';
import { getOrganizations } from '$lib/utils/services/org';
import { goto } from '$app/navigation';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import isEmpty from 'lodash/isEmpty';
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
import { page } from '$app/stores';
import { setTheme } from '$lib/utils/functions/theme';
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
import { supabase } from '$lib/utils/functions/supabase';
import { initPosthog } from '$lib/utils/services/posthog';
import { initSentry } from '$lib/utils/services/sentry';
export function setupAnalytics() {
// Set up sentry
@@ -29,193 +14,3 @@ export function setupAnalytics() {
localStorage.setItem('umami.disabled', '1');
}
}
function setAnalyticsUser() {
const profileStore = get(profile);
if (!profileStore.id) return;
setSentryUser({
id: profileStore.id,
username: profileStore.username,
email: profileStore.email,
fullname: profileStore.fullname
});
identifyPosthogUser(profileStore.id, {
email: profileStore.email,
name: profileStore.fullname
});
}
export async function getProfile({
path,
queryParam,
isOrgSite,
orgSiteName
}: {
path: string;
queryParam: string;
isOrgSite: boolean;
orgSiteName: string;
}) {
const pageStore = get(page);
const profileStore = get(profile);
const currentOrgStore = get(currentOrg);
const currentOrgDomainStore = get(currentOrgDomain);
const params = new URLSearchParams(window.location.search);
// Get user profile
const {
data: { session }
} = await supabase.auth.getSession();
const { user: authUser } = session || {};
console.log('Get user', authUser);
if (!authUser && !isPublicRoute(pageStore.url?.pathname)) {
return goto('/login?redirect=/' + path + queryParam);
}
// Skip refetching profile, if already in store
if (profileStore.id) {
handleLocaleChange(profileStore.locale);
return;
}
// Check if user has profile
const {
data: profileData,
error,
status
} = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single();
console.log('Get profile', profileData);
if (error && !profileData && status === 406 && authUser) {
// User wasn't found, create profile
console.log(`User wasn't found, create profile`);
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
const isGoogleAuth = !!authUser.app_metadata?.providers?.includes('google');
const { data: newProfileData, error } = await supabase
.from('profile')
.insert({
id: authUser.id,
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
fullname: regexUsernameMatch[1],
email: authUser.email,
is_email_verified: isGoogleAuth,
verified_at: isGoogleAuth ? new Date().toDateString() : undefined
})
.select();
// Profile created, go to onboarding or lms
if (!error && newProfileData) {
user.update((_user) => ({
..._user,
fetchingUser: false,
isLoggedIn: true,
currentSession: authUser
}));
profile.set(newProfileData[0]);
setAnalyticsUser();
// Fetch language
handleLocaleChange(newProfileData[0].locale);
if (isOrgSite) {
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: currentOrgStore.id,
profile_id: profileStore.id,
role_id: 3
})
.select();
if (error) {
console.error('Error adding user to organisation', error);
} else {
console.log('Success adding user to organisation', data);
const memberId = data?.[0]?.id || '';
currentOrg.update((_currentOrg) => ({
..._currentOrg,
memberId
}));
}
if (params.get('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
goto('/lms');
}
return;
}
// On invite page, don't go to onboarding
if (!path.includes('invite')) {
goto(ROUTE.ONBOARDING);
}
}
user.update((_user) => ({
..._user,
fetchingUser: false
}));
} else if (profileData) {
// Profile exists, go to profile page
user.update((_user) => ({
..._user,
fetchingUser: false,
isLoggedIn: true,
currentSession: authUser
}));
profile.set(profileData);
// Set user in sentry
setAnalyticsUser();
handleLocaleChange(profileData.locale);
const orgRes = await getOrganizations(profileData.id, isOrgSite, orgSiteName);
const isStudentAccount = orgRes.currentOrg.role_id == ROLE.STUDENT;
// student redirect
if (isOrgSite) {
if (params.has('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
goto('/lms');
}
} else {
if (isStudentAccount) {
// Check if the student logged into the dashboard.
console.log('Student logged into dashboard');
if (dev) {
goto('/lms');
} else {
window.location.replace(`${currentOrgDomainStore}/lms`);
}
} else if (isEmpty(orgRes.orgs) && !path.includes('invite')) {
// Not on invite page or no org, go to onboarding
goto(ROUTE.ONBOARDING);
} else if (params.has('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
// By default redirect to first organization
goto(`/org/${orgRes.currentOrg.siteName}`);
}
}
setTheme(orgRes?.currentOrg?.theme);
}
if (!profileData && !isPublicRoute(pageStore.url?.pathname)) {
goto('/login?redirect=/' + path);
}
}
@@ -1,23 +1,23 @@
import posthog from 'posthog-js';
import { supabase } from '$lib/utils/functions/supabase';
import { appInitApi } from '$lib/features/app/init.svelte';
import { authClient } from '$lib/utils/services/auth/client';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
import { goto } from '$app/navigation';
import posthog from 'posthog-js';
import { resolve } from '$app/paths';
export async function logout(redirect = true) {
const { error } = await supabase.auth.signOut();
const { error } = await authClient.signOut();
if (error) {
console.error('Error logging out: ', error);
}
user.set(defaultUserState);
profile.set(defaultProfileState);
appInitApi.reset();
capturePosthogEvent('user_logged_out');
posthog.reset();
if (redirect) {
goto('/login');
goto(resolve('/login', {}));
}
}
@@ -1,4 +1,5 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
export function genQuizPin(): number {
const minm = 100000;
@@ -20,5 +21,9 @@ export function openUpgradeModal() {
const searchParams = url.searchParams.toString();
goto(window.location.pathname + `?${searchParams}`);
goto(resolve(window.location.pathname + `?${searchParams}`, {}));
}
export function getShortOrgName(name: string) {
return name?.substring(0, 2)?.toUpperCase() || '';
}
@@ -1,8 +1,14 @@
import { PUBLIC_ROUTES } from '$lib/utils/constants/routes';
import { PUBLIC_API_ROUTES, PUBLIC_ROUTES } from '$lib/utils/constants/routes';
export default (route = '') => {
export const isPublicRoute = (route = '') => {
return PUBLIC_ROUTES.some((publicRoute) => {
const regex = new RegExp(publicRoute, 'g');
return regex.test(route);
});
};
export default isPublicRoute;
export const isPublicApiRoute = (pathname = '') => {
return PUBLIC_API_ROUTES.some((route) => pathname.includes(route));
};
@@ -1,3 +1,3 @@
export default (path: string): boolean => {
return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '';
return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '/';
};
@@ -11,6 +11,8 @@ export const getSupabase = () => {
return supabase;
};
getSupabase();
export const hasSession = async () => {
const { data } = await getSupabase().auth.getSession();
console.log('has session', data);
@@ -1,4 +1,4 @@
import { LOCALE } from '$lib/utils/types';
import type { TLocale } from '@cio/db/types';
import i18n from '@sveltekit-i18n/base';
import parser from '@sveltekit-i18n/parser-icu';
import { writable } from 'svelte/store';
@@ -72,7 +72,7 @@ loading.subscribe(async ($loading) => {
}
});
export function handleLocaleChange(newLocale: LOCALE) {
export function handleLocaleChange(newLocale: TLocale) {
if (!newLocale) {
return;
}
@@ -82,7 +82,7 @@ export function handleLocaleChange(newLocale: LOCALE) {
selectedLocale.set(newLocale);
}
export function lessonFallbackNote(note: string, translation: Record<LOCALE, string>, locale: LOCALE) {
export function lessonFallbackNote(note: string, translation: Record<TLocale, string>, locale: TLocale) {
if (!translation) {
return note;
}
@@ -90,7 +90,7 @@ export function lessonFallbackNote(note: string, translation: Record<LOCALE, str
const content = translation[locale];
// if locale is english and no translated content for english but note exists
if (locale === LOCALE.EN && !content && note?.length) {
if (locale === 'en' && !content && note?.length) {
return note;
}
@@ -1,8 +1,7 @@
import z from 'zod';
import { get } from 'svelte/store';
import isNumber from 'lodash/isNumber';
import { t } from '$lib/utils/functions/translations';
import { validateEmail } from './validateEmail';
import { t, initialized } from '$lib/utils/functions/translations';
import z from 'zod';
function getOrgNameValidation() {
return z
@@ -55,7 +54,7 @@ export const processErrors = (error, mapToId?: boolean) => {
if (mapToId) {
let value = '';
path.forEach((p, i) => {
path.forEach((p) => {
const formatP = isNumber(p) ? `[${p}]` : p;
value += !value ? formatP : `.${formatP}`;
@@ -113,28 +112,6 @@ export const coursePaymentValidation = (fields = {}) => {
return processErrors(error);
};
export const resetValidation = (fields = {}) => {
const schema = z.object({
password: z.string().min(6, {
message: `${t.get('validations.reset.password.min_char')}`
})
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const forgotValidation = (fields = {}) => {
const schema = z.object({
email: z.string().email({
message: `${t.get('validations.forgot.invalid_email')}`
})
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const orgLandingpageValidation = (fields = {}) => {
const schema = z.object({
name: z.string().min(6, {
@@ -155,58 +132,6 @@ export const orgLandingpageValidation = (fields = {}) => {
return processErrors(error);
};
export const onboardingValidation = (fields = {}, step) => {
const onboardingValidationSchema = {
stepOne: z.object({
fullname: z.string().min(5, { message: `${t.get('validations.onboarding.step_one.full_name.min_char')}` }),
orgName: getOrgNameValidation(),
siteName: getSiteNameValidation()
}),
stepTwo: z.object({
goal: z
.string({
required_error: `${t.get('validations.onboarding.step_two.goal.required')}`
})
.min(1),
source: z
.string({
required_error: `${t.get('validations.onboarding.step_two.source.required')}`
})
.min(1)
})
};
const schema = step === 1 ? onboardingValidationSchema.stepOne : onboardingValidationSchema.stepTwo;
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const updateProfileValidation = (fields = {}) => {
const schema = z.object({
email: z.string().email({ message: 'validations.user_profile.email' }),
username: z.string().nonempty({ message: 'validations.user_profile.username' }),
fullname: z.string().min(5, { message: 'validations.user_profile.fullname' })
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
// export const createTemplateExerciseValidation = (fields = {}) => {
// const schema = z.object({
// orgName: z.string().min(5, {
// message: 'Must be 5 or more characters long',
// }),
// siteName: z.string().min(5, {
// message: 'Must be 5 or more characters long',
// })
// });
// const { error } = schema.safeParse(fields);
// return processErrors(error);
// };
export const createNewsfeedValidation = (newPost) => {
const schema = z.object({
newPost: getNewsfeedValidation()