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
+59
View File
@@ -0,0 +1,59 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
import type { TProfile } from '@cio/db/types';
import { getOrganizationByProfileId } from '@cio/db/queries/organization';
/**
* Fetches account data including profile and organizations for a user
* @param userId - The user ID to fetch account data for
* @returns Account data with profile and organizations
*/
export async function getAccountData(userId: string) {
const [profile, organizations] = await Promise.all([getProfileById(userId), getOrganizationByProfileId(userId)]);
if (!profile) {
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
}
return {
profile,
organizations
};
}
/**
* Updates profile data for a user
* @param userId - The user ID to update profile for
* @param data - Partial profile data to update (excluding email, id, createdAt, updatedAt)
* @returns Updated profile data
*/
export async function updateUser(
userId: string,
data: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>>
) {
try {
const updatedProfile = await updateProfile(userId, data);
if (!updatedProfile) {
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
}
return updatedProfile;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
// Handle unique constraint violations
if (error instanceof Error && error.message.includes('profile_username_key')) {
throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username');
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update profile',
ErrorCodes.PROFILE_UPDATE_FAILED,
500
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './account';
export * from './onboarding';
export * from './mail';
+4 -10
View File
@@ -1,13 +1,9 @@
import { extractNameAndEmail, withEmailTemplate } from '@api/utils/mail';
import { nodemailerTransporter, zohoClient } from '@api/utils/email';
import { extractNameAndEmail, nodemailerTransporter, withEmailTemplate, zohoClient } from '@api/utils/mail';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '@api/types/mail';
import type { TEmailData } from '@cio/utils/validation/mail';
import type { Transporter } from 'nodemailer';
import { env } from '@api/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
@@ -20,9 +16,7 @@ nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
@@ -54,7 +48,7 @@ export async function sendWithNodemailer(
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
+99
View File
@@ -0,0 +1,99 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TProfile, TUser } from '@cio/db/types';
import {
checkSiteNameExists,
createOrganization,
createOrganizationMember,
getOrganizationByProfileId
} from '@cio/db/queries';
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { sendEmail } from '@cio/email';
export async function createOrganizationWithOwner(
profileId: string,
input: {
fullname?: string;
orgName: string;
siteName: string;
}
) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
console.error('Site name already exists:', input.siteName);
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
// Business Logic: Create org and member in a transaction
try {
const result = await db.transaction(async () => {
const organization = await createOrganization({
name: input.orgName,
siteName: input.siteName
});
const member = await createOrganizationMember({
organizationId: organization.id,
profileId,
roleId: ROLE.ADMIN,
verified: true
});
return { organization, member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId(profileId);
return {
organization: result.organization,
member: result.member,
organizations
};
} catch (error) {
console.error('Error creating organization:', error);
// Handle database constraint violations
if (error.code === '23505') {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError(error, ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
export async function updateUserOnboarding(userId: string, data: Partial<TProfile>) {
try {
const updatedProfile = await updateProfile(userId, data);
if (!updatedProfile) {
throw new AppError('Failed to update profile - profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
return updatedProfile;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(error, ErrorCodes.PROFILE_UPDATE_FAILED, 500);
}
}
export async function completeOnboarding(user: Partial<TUser>) {
try {
const profile = await getProfileById(user.id);
if (!profile) {
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
await sendEmail('welcome', {
to: user.email,
fields: {
name: profile.fullname
}
});
} catch (error) {
throw new AppError(error, ErrorCodes.INTERNAL_ERROR, 500);
}
}
+165
View File
@@ -0,0 +1,165 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewOrganizationPlan, TOrganizationPlan, TPlan } from '@db/types';
import {
cancelOrganizationPlan,
createOrganizationPlan,
getOrganizationAudience,
getOrganizationTeam,
getOrganizations,
updateOrganizationPlan
} from '@cio/db/queries/organization';
import { getCoursesBySiteName } from '@cio/db/queries/course';
/**
* Gets organizations with optional filters
* @param filters - Filter options (siteName, customDomain, isCustomDomainVerified)
* @returns Array of organizations with plans
*/
export async function getOrganizationsWithFilters(filters?: {
siteName?: string;
customDomain?: string;
isCustomDomainVerified?: boolean;
}) {
try {
const organizations = await getOrganizations(filters);
return organizations;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organizations',
ErrorCodes.ORGANIZATION_NOT_FOUND,
500
);
}
}
/**
* Gets organization team members (non-students)
* @param orgId - The organization ID
* @returns Array of team members
*/
export async function getOrgTeam(orgId: string) {
try {
const team = await getOrganizationTeam(orgId);
return team;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization team',
ErrorCodes.ORG_TEAM_FETCH_FAILED,
500
);
}
}
/**
* Gets organization audience (students)
* @param orgId - The organization ID
* @returns Array of student profiles
*/
export async function getOrgAudience(orgId: string) {
try {
const audience = await getOrganizationAudience(orgId);
return audience;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization audience',
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
500
);
}
}
/**
* Gets courses by organization siteName
* @param siteName - The organization site name
* @returns Array of courses
*/
export async function getCoursesByOrgSiteName(siteName: string) {
try {
const courses = await getCoursesBySiteName(siteName);
return courses;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch courses',
ErrorCodes.COURSES_FETCH_FAILED,
500
);
}
}
/**
* Creates a new organization plan
* @param data - Organization plan creation data
* @returns Created organization plan
*/
export async function createOrgPlan(data: TNewOrganizationPlan) {
try {
const plan = await createOrganizationPlan({
orgId: data.orgId,
planName: data.planName,
subscriptionId: data.subscriptionId,
triggeredBy: data.triggeredBy,
payload: data.payload,
isActive: true,
provider: 'polar'
});
return plan;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to create organization plan',
ErrorCodes.ORG_PLAN_CREATE_FAILED,
500
);
}
}
/**
* Updates an organization plan
* @param subscriptionId - Subscription ID
* @param payload - Payload data to update
* @returns Updated organization plan
*/
export async function updateOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
try {
const plan = await updateOrganizationPlan(subscriptionId, payload);
if (!plan) {
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
}
return plan;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update organization plan',
ErrorCodes.ORG_PLAN_UPDATE_FAILED,
500
);
}
}
/**
* Cancels an organization plan
* @param subscriptionId - Subscription ID
* @param payload - Payload data to update
* @returns Updated organization plan
*/
export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
try {
const plan = await cancelOrganizationPlan(subscriptionId, payload);
if (!plan) {
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
}
return plan;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to cancel organization plan',
ErrorCodes.ORG_PLAN_CANCEL_FAILED,
500
);
}
}