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,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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user