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,37 @@
import { BaseApiWithErrors } from '$lib/utils/services/api';
import type { TForgotPasswordForm } from '../utils/types';
import { authClient } from '$lib/utils/services/auth/client';
import { forgotPasswordValidation } from '../utils/validation';
import { snackbar } from '$lib/components/Snackbar/store';
export class ForgotApi extends BaseApiWithErrors {
async submit(fields: TForgotPasswordForm) {
const errors = forgotPasswordValidation(fields);
if (errors) {
this.errors = errors;
return;
}
try {
this.isLoading = true;
this.errors = {};
this.success = false;
const { error } = await authClient.requestPasswordReset({
email: fields.email,
redirectTo: window.location.origin + '/reset'
});
if (error) throw error;
this.success = true;
} catch (error) {
console.error(error);
snackbar.error('snackbar.something');
} finally {
this.isLoading = false;
}
}
}
export const forgotApi = new ForgotApi();
@@ -0,0 +1,207 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { TLocale, TProfile } from '@cio/db/types';
import { ZChangeEmail, ZProfileUpdateForm } from '@cio/utils/validation/account';
import type { TProfileUpdateForm as TProfileUpdateFormSchema } from '@cio/utils/validation/account';
import { authClient } from '$lib/utils/services/auth/client';
import generateUUID from '$lib/utils/functions/generateUUID';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile as profileStore } from '$lib/utils/store/user';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
export interface TProfileUpdateForm extends Omit<TProfileUpdateFormSchema, 'avatarUrl'> {
avatar?: string | File | undefined;
}
export interface TChangeEmailForm {
newEmail: string;
callbackURL?: string;
}
export class ProfileApi extends BaseApiWithErrors {
private validateForm(fields: TProfileUpdateForm): boolean {
const validationData = {
fullname: fields.fullname,
username: fields.username,
locale: fields.locale
};
const result = ZProfileUpdateForm.safeParse(validationData);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return false;
}
return true;
}
private async uploadAvatar(avatar: File): Promise<string | undefined> {
const filename = `user/${generateUUID()}.webp`;
const { data: uploadData, error: uploadError } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (uploadError) throw uploadError;
if (uploadData) {
const { data: urlData } = await supabase.storage.from('avatars').getPublicUrl(filename);
return urlData.publicUrl;
}
return undefined;
}
private async updateUserInfo(
fields: TProfileUpdateForm,
currentProfile: TProfile,
avatarUrl?: string
): Promise<void> {
const userUpdates: { name?: string; image?: string } = {};
if (fields.fullname !== currentProfile.fullname) {
userUpdates.name = fields.fullname;
}
if (avatarUrl) {
userUpdates.image = avatarUrl;
}
if (Object.keys(userUpdates).length > 0) {
const { error: userError } = await authClient.updateUser(userUpdates);
if (userError) throw userError;
}
}
private buildProfileUpdates(
fields: TProfileUpdateForm,
currentProfile: TProfile,
locale: TLocale | undefined,
avatarUrl?: string
): Partial<TProfile> {
const profileUpdates: Partial<TProfile> = {};
if (fields.fullname !== currentProfile.fullname) {
profileUpdates.fullname = fields.fullname;
}
if (fields.username !== currentProfile.username) {
profileUpdates.username = fields.username;
}
if (locale && locale !== currentProfile.locale) {
profileUpdates.locale = locale;
}
if (avatarUrl) {
profileUpdates.avatarUrl = avatarUrl;
}
return profileUpdates;
}
private async updateProfile(profileUpdates: Partial<TProfile>): Promise<void> {
if (Object.keys(profileUpdates).length === 0) {
return;
}
await this.execute<typeof classroomio.account.user.$put>({
requestFn: () => classroomio.account.user.$put({ json: profileUpdates }),
logContext: 'updating profile',
onSuccess: (response) => {
profileStore.update((_profile) => ({
..._profile,
...response.profile
}));
}
});
}
private handleError(error: unknown): void {
console.error(error);
const message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
this.errors.username = 'snackbar.lms.error.username_exists';
snackbar.error('snackbar.lms.error.username_exists');
} else {
this.errors.general = message;
snackbar.error(`snackbar.lms.error.update: ${message}`);
}
}
async submit(fields: TProfileUpdateForm, currentProfile: TProfile, hasLangChanged: boolean, locale?: TLocale) {
if (!this.validateForm(fields)) {
return;
}
try {
this.isLoading = true;
this.errors = {};
this.success = false;
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await this.uploadAvatar(fields.avatar);
}
// Update user info via Better Auth (name and image)
await this.updateUserInfo(fields, currentProfile, avatarUrl);
// Update profile-specific fields via API route
const profileUpdates = this.buildProfileUpdates(fields, currentProfile, locale, avatarUrl);
await this.updateProfile(profileUpdates);
// Handle locale change if needed
if (hasLangChanged && locale) {
handleLocaleChange(locale);
}
snackbar.success('snackbar.course_settings.success.update_successful');
this.success = true;
} catch (error) {
this.handleError(error);
} finally {
this.isLoading = false;
}
}
async changeEmail(fields: TChangeEmailForm) {
// Validate email format
const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail });
console.log('result', result);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
try {
this.isLoading = true;
this.errors = {};
this.success = false;
const { error } = await authClient.changeEmail({
newEmail: fields.newEmail,
callbackURL: window.location.origin
});
if (error) throw new Error(error.message);
snackbar.success();
this.success = true;
} catch (error) {
console.error(error);
const message = error instanceof Error ? error.message : `${error}`;
this.errors.email = message;
snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`);
} finally {
this.isLoading = false;
}
}
}
export const profileApi = new ProfileApi();
@@ -0,0 +1,47 @@
import { BaseApiWithErrors } from '$lib/utils/services/api';
import type { TResetPasswordForm } from '../utils/types';
import { authClient } from '$lib/utils/services/auth/client';
import { goto } from '$app/navigation';
import { resetPasswordValidation } from '../utils/validation';
import { resolve } from '$app/paths';
import { snackbar } from '$lib/components/Snackbar/store';
export class ResetApi extends BaseApiWithErrors {
async submit(fields: TResetPasswordForm) {
const errors = resetPasswordValidation(fields);
if (errors) {
this.errors = errors;
return;
}
try {
this.isLoading = true;
this.errors = {};
this.success = false;
const { error } = await authClient.resetPassword({
newPassword: fields.password, // required
token: fields.token // required
});
if (error) throw new Error(error.message);
this.success = true;
snackbar.success();
goto(resolve('/login', {}));
} catch (error) {
console.error(error);
const message = error instanceof Error ? error.message : String(error);
snackbar.error(message);
} finally {
this.isLoading = false;
}
}
setError(_errors: Record<string, string>) {
this.errors = _errors;
}
}
export const resetApi = new ResetApi();
@@ -0,0 +1,13 @@
import * as z from 'zod';
export const ZForgotPasswordForm = z.object({
email: z.email()
});
export type TForgotPasswordForm = z.infer<typeof ZForgotPasswordForm>;
export const ZResetPasswordForm = z.object({
password: z.string().min(8),
confirmPassword: z.string().min(8),
token: z.string()
});
export type TResetPasswordForm = z.infer<typeof ZResetPasswordForm>;
@@ -0,0 +1,19 @@
import type { TForgotPasswordForm, TResetPasswordForm } from './types';
import { ForgotPasswordSchema } from '@cio/utils/validation/auth/forgot';
import { ResetPasswordSchema } from '@cio/utils/validation/auth/reset';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
export const forgotPasswordValidation = (field: TForgotPasswordForm) => {
const result = ForgotPasswordSchema.safeParse(field);
if (!result.success) {
return mapZodErrorsToTranslations(result.error);
}
};
export const resetPasswordValidation = (field: TResetPasswordForm) => {
const result = ResetPasswordSchema.safeParse(field);
if (!result.success) {
return mapZodErrorsToTranslations(result.error);
}
};