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
+49
View File
@@ -0,0 +1,49 @@
import { getAccountData, updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono()
.get('/', authMiddleware, async (c) => {
const user = c.get('user');
try {
console.time('accountRouter');
const accountData = await getAccountData(user.id);
console.timeEnd('accountRouter');
return c.json(
{
success: true,
user,
profile: accountData.profile,
organizations: accountData.organizations
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch account data');
}
})
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
const user = c.get('user');
try {
const validatedData = c.req.valid('json');
const updatedProfile = await updateUser(user.id, validatedData);
return c.json(
{
success: true,
profile: updatedProfile
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
});
+1
View File
@@ -0,0 +1 @@
export * from './account';
+26 -36
View File
@@ -1,42 +1,32 @@
import { Hono } from 'hono';
import { ZCourseClone, TCourseClone } from '@api/types/course';
import { zValidator } from '@hono/zod-validator';
import { cloneCourse } from '@api/services/course/clone';
import type { CloneCourseErrorResponse, CloneCourseResponse } from '@api/types/database';
import { Hono } from '@api/utils/hono';
import { ZCourseClone } from '@cio/utils/validation/course';
import { authMiddleware } from '@api/middlewares/auth';
import type { CloneCourseResponse, CloneCourseErrorResponse } from '@api/types/database';
import type { User } from '@supabase/supabase-js';
import { cloneCourse } from '@api/services/course/clone';
import { zValidator } from '@hono/zod-validator';
type Variables = {
user: User;
};
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
try {
const validatedData = c.req.valid('json');
const { id, title, description, slug, organizationId } = validatedData;
export const cloneRouter = new Hono<{ Variables: Variables }>().post(
'/',
authMiddleware,
zValidator('json', ZCourseClone),
async (c) => {
try {
const validatedData = (c.req.valid as (key: 'json') => TCourseClone)('json');
const { id, title, description, slug, organizationId } = validatedData;
const user = c.get('user');
// Get user from context (set by authMiddleware)
const user = c.get('user');
// Clone the course
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
// Clone the course
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
const successResponse: CloneCourseResponse = {
success: true,
course: newCourse
};
return c.json(successResponse, 200);
} catch (error) {
console.error('Error cloning course:', error);
const errorResponse: CloneCourseErrorResponse = {
error: 'Failed to clone course',
details: error instanceof Error ? error.message : 'Unknown error'
};
return c.json(errorResponse, 500);
}
const successResponse: CloneCourseResponse = {
success: true,
course: newCourse
};
return c.json(successResponse, 200);
} catch (error) {
console.error('Error cloning course:', error);
const errorResponse: CloneCourseErrorResponse = {
error: 'Failed to clone course',
details: error instanceof Error ? error.message : 'Unknown error'
};
return c.json(errorResponse, 500);
}
);
});
+6 -9
View File
@@ -1,25 +1,22 @@
import { Hono } from 'hono';
import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '@api/types/course';
import { ZCertificateDownload, ZCourseDownloadContent } from '@cio/utils/validation/course';
import { Hono } from '@api/utils/hono';
import { cloneRouter } from '@api/routes/course/clone';
import { generateCertificate } from '@api/utils/certificate';
import { generateCoursePdf } from '@api/utils/course';
import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson';
import { presignRouter } from '@api/routes/course/presign';
import { cloneRouter } from '@api/routes/course/clone';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = (c.req.valid as (key: 'json') => TCertificateDownload)('json');
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
);
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
return c.body(
new ReadableStream({
@@ -31,7 +28,7 @@ export const courseRouter = new Hono()
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = (c.req.valid as (key: 'json') => TCourseDownloadContent)('json');
const validatedData = c.req.valid('json');
const pdfBuffer = await generateCoursePdf(validatedData);
+1
View File
@@ -0,0 +1 @@
export * from './course';
+2 -2
View File
@@ -1,9 +1,9 @@
import { Context, Hono } from 'hono';
import { Hono } from '@api/utils/hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono().get('/', async (c: Context) => {
export const katexRouter = new Hono().get('/', async (c) => {
try {
const original = c.req.raw.url;
+15 -19
View File
@@ -1,24 +1,20 @@
import { Hono } from 'hono';
import { TLessonDownloadContent, ZLessonDownloadContent } from '@api/types/course/lesson';
import { Hono } from '@api/utils/hono';
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
import { generateLessonPdf } from '@api/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post(
'/download/pdf',
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = (c.req.valid as (key: 'json') => TLessonDownloadContent)('json');
export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateLessonPdf(validatedData);
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
});
+2 -2
View File
@@ -3,13 +3,13 @@ import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '@api/types/course';
} from '@cio/utils/validation/course';
import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '@api/constants/upload';
import { CLOUDFLARE } from '@api/constants';
import type { GetSignedUrlParameters } from '@api/utils/s3';
import { Hono } from 'hono';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { generateFileKey } from '@api/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
-48
View File
@@ -1,48 +0,0 @@
import { sendWithNodemailer, sendWithZoho } from '@api/services/mail';
import { Hono } from 'hono';
import { ZSendEmailValidation } from '@api/types/mail';
import { env } from '@api/config/env';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post(
'/send',
zValidator('json', ZSendEmailValidation),
async (c) => {
const validatedData = c.req.valid('json');
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
}
);
+1
View File
@@ -0,0 +1 @@
export * from './mail';
+31
View File
@@ -0,0 +1,31 @@
import { Hono } from '@api/utils/hono';
import { ZDeliverEmail } from '@cio/utils/validation/mail';
import { deliverEmail } from '@cio/email';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json');
try {
// Use service to send emails
const results = await deliverEmail(validatedData);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
code: 'EMAIL_SEND_PARTIAL_FAILURE',
details: results
},
500
);
}
return c.json({ success: true, data: results }, 200);
} catch (error) {
return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED');
}
});
+1
View File
@@ -0,0 +1 @@
export * from './onboarding';
@@ -0,0 +1,44 @@
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
import { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const onboardingRouter = new Hono()
.post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => {
try {
const user = c.get('user');
const { fullname, orgName, siteName } = c.req.valid('json');
const result = await createOrganizationWithOwner(user.id, { fullname, orgName, siteName });
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
})
.post('/step2', authMiddleware, zValidator('json', ZOnboardingStep2), async (c) => {
try {
const user = c.get('user');
const { goal, source, fullname } = c.req.valid('json');
const result = await updateUserOnboarding(user.id, { goal, source, fullname });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update onboarding data');
}
})
.post('/complete', authMiddleware, async (c) => {
try {
const user = c.get('user');
await completeOnboarding(user);
return c.json({ success: true }, 200);
} catch (error) {
return handleError(c, error, 'Failed to complete onboarding');
}
});
@@ -0,0 +1 @@
export * from './organization';
@@ -0,0 +1,172 @@
import {
ZCancelOrgPlan,
ZCreateOrgPlan,
ZGetCoursesBySiteName,
ZGetOrganizationAudience,
ZGetOrganizationTeam,
ZGetOrganizations,
ZUpdateOrgPlan
} from '@cio/utils/validation/organization';
import {
cancelOrgPlan,
createOrgPlan,
getCoursesByOrgSiteName,
getOrgAudience,
getOrgTeam,
getOrganizationsWithFilters,
updateOrgPlan
} from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
/**
* GET /organization
* Gets organizations with optional filters
* Query params: siteName (string, optional), customDomain (string, optional), isCustomDomainVerified (boolean, optional)
*/
.get('/', zValidator('query', ZGetOrganizations), async (c) => {
try {
const filters = c.req.valid('query');
const organizations = await getOrganizationsWithFilters(filters);
return c.json(
{
success: true,
data: organizations
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organizations');
}
})
/**
* GET /organization/:orgId/team
* Gets organization team members (non-students)
* Requires authentication
*/
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
try {
const { orgId } = c.req.valid('param');
const team = await getOrgTeam(orgId);
return c.json(
{
success: true,
data: team
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization team');
}
})
/**
* GET /organization/:orgId/audience
* Gets organization audience (students)
* Requires authentication
*/
.get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => {
try {
const { orgId } = c.req.valid('param');
const audience = await getOrgAudience(orgId);
return c.json(
{
success: true,
data: audience
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization audience');
}
})
/**
* GET /organization/courses
* Gets courses by organization siteName
* Query params: siteName (string)
*/
.get('/courses', zValidator('query', ZGetCoursesBySiteName), async (c) => {
try {
const { siteName } = c.req.valid('query');
const courses = await getCoursesByOrgSiteName(siteName);
return c.json(
{
success: true,
data: courses
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch courses');
}
})
/**
* POST /organization/plan
* Creates a new organization plan
* Requires authentication
*/
.post('/plan', authMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
try {
const data = c.req.valid('json');
const plan = await createOrgPlan(data);
return c.json(
{
success: true,
data: plan
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create organization plan');
}
})
/**
* PUT /organization/plan
* Updates an organization plan by subscription ID
* Requires authentication
*/
.put('/plan', authMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await updateOrgPlan(subscriptionId, payload);
return c.json(
{
success: true,
data: plan
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update organization plan');
}
})
/**
* POST /organization/plan/cancel
* Cancels an organization plan by subscription ID
* Requires authentication
*/
.post('/plan/cancel', authMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await cancelOrgPlan(subscriptionId, payload);
return c.json(
{
success: true,
data: plan
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to cancel organization plan');
}
});