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,104 @@
<script lang="ts">
import { MediaQuery } from 'svelte/reactivity';
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
import * as Drawer from '@cio/ui/base/drawer';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { buttonVariants } from '@cio/ui/base/button';
import { page } from '$app/state';
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { getOrgNavigationItems } from './org-navigation';
import { generateBreadcrumbs } from './breadcrumb';
import OrgSwitcher from '../sidebar/org-sidebar/org-switcher.svelte';
const navItems = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
const breadcrumbs = $derived(
generateBreadcrumbs(
page.url.pathname,
page.url.search,
navItems,
$currentOrgPath,
page.data as { breadcrumb?: string } | undefined
)
);
let open = $state(false);
const isDesktop = new MediaQuery('(min-width: 768px)');
const ITEMS_TO_DISPLAY = $derived(isDesktop.current ? 2 : 1); // Show last 2 items (since we have org switcher as first)
</script>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item>
<OrgSwitcher variant="breadcrumb" />
</Breadcrumb.Item>
{#if breadcrumbs.length > 0}
<Breadcrumb.Separator />
{#if breadcrumbs.length > ITEMS_TO_DISPLAY}
<!-- Show ellipsis for hidden items -->
<Breadcrumb.Item>
{#if isDesktop.current}
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="flex items-center gap-1" aria-label="Toggle menu">
<Breadcrumb.Ellipsis class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
<DropdownMenu.Item>
<a href={breadcrumb.href ? breadcrumb.href : '##'}>
{breadcrumb.label}
</a>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<Drawer.Root bind:open>
<Drawer.Trigger aria-label="Toggle Menu">
<Breadcrumb.Ellipsis class="size-4" />
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header class="text-start">
<Drawer.Title>Navigate to</Drawer.Title>
<Drawer.Description>Select a page to navigate to.</Drawer.Description>
</Drawer.Header>
<div class="grid gap-1 px-4">
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
<a href={breadcrumb.href ? breadcrumb.href : '##'} class="py-1 text-sm">
{breadcrumb.label}
</a>
{/each}
</div>
<Drawer.Footer class="pt-4">
<Drawer.Close class={buttonVariants({ variant: 'outline' })}>Close</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{/if}
</Breadcrumb.Item>
<Breadcrumb.Separator />
{/if}
<!-- Show last ITEMS_TO_DISPLAY items (or all if <= ITEMS_TO_DISPLAY) -->
{#each breadcrumbs.slice(-ITEMS_TO_DISPLAY) as breadcrumb, index (breadcrumb.label)}
<Breadcrumb.Item>
{@const isLast = index === breadcrumbs.slice(-ITEMS_TO_DISPLAY).length - 1}
{#if !isLast}
<Breadcrumb.Link href={breadcrumb.href} class="max-w-20 truncate md:max-w-none">
{breadcrumb.label}
</Breadcrumb.Link>
<Breadcrumb.Separator />
{:else}
<Breadcrumb.Page class="max-w-20 truncate md:max-w-none">
{breadcrumb.label}
</Breadcrumb.Page>
{/if}
</Breadcrumb.Item>
{/each}
{/if}
</Breadcrumb.List>
</Breadcrumb.Root>
@@ -0,0 +1,42 @@
<script lang="ts">
import { Separator } from '@cio/ui/base/separator';
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import * as Popover from '@cio/ui/base/popover';
import Search from '../search.svelte';
import AppBreadcrumbs from './app-breadcrumbs.svelte';
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
<div class="h-4 w-2">
<Separator orientation="vertical" />
</div>
<AppBreadcrumbs />
<span class="grow"></span>
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
<BellIcon class="custom rounded-full" />
</Button>
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm">Your notifications will show here</p>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</header>
@@ -0,0 +1,108 @@
import type { NavItem } from './org-navigation';
import { isActive } from '$lib/utils/functions/app';
export interface BreadcrumbItem {
label: string;
href: string;
}
/**
* Generate breadcrumbs from the current pathname using navigation structure
*/
export function generateBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
currentOrgPath: string,
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const pathWithQuery = pathname + searchParams;
// Remove the org path prefix to get the relative path
const relativePath = pathname.replace(currentOrgPath, '').replace(/^\//, '') || '';
const pathSegments = relativePath.split('/').filter(Boolean);
// Handle root/dashboard
if (!relativePath || relativePath === '') {
return breadcrumbs;
}
// Find the matching top-level nav item
const firstSegment = pathSegments[0];
let matchedNavItem: NavItem | undefined;
for (const item of navItems) {
// Use path for matching (works for both hash URLs and regular URLs)
const itemPath = item.path.replace(/^\//, '');
if (itemPath === firstSegment || (itemPath === '' && firstSegment === undefined)) {
matchedNavItem = item;
break;
}
}
if (!matchedNavItem) {
// Try to match by checking if pathname matches the item path
for (const item of navItems) {
const fullItemPath = `${currentOrgPath}${item.path}`;
if (isActive(pathname, fullItemPath)) {
matchedNavItem = item;
break;
}
}
}
if (matchedNavItem) {
// Handle hash URLs (like settings) - use the actual path instead of '#'
const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${matchedNavItem.path}` : matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.title,
href: href
});
}
// Handle items with query params (like settings tabs)
if (matchedNavItem?.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true));
if (subItem) {
breadcrumbs.push({
label: subItem.title,
href: subItem.url
});
return breadcrumbs;
}
}
// Handle nested routes
if (pathSegments.length > 1 && matchedNavItem) {
const secondSegment = pathSegments[1];
// Check for static nested routes (like community/ask, settings/customize-lms)
if (matchedNavItem.nestedRoutes) {
const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey, // Could be translated if needed
href: `${currentOrgPath}${matchedNavItem.path}/${nestedRoute.path}`
});
return breadcrumbs;
}
}
// Check for dynamic segments (like community/[slug], quiz/[slug])
if (matchedNavItem.supportsDynamicSegment && secondSegment) {
// Check if it's not a static nested route
const isStaticRoute = matchedNavItem.nestedRoutes?.some((route) => route.path === secondSegment);
if (!isStaticRoute && pageData?.breadcrumb) {
breadcrumbs.push({
label: pageData.breadcrumb,
href: `${currentOrgPath}${matchedNavItem.path}/${secondSegment}`
});
}
return breadcrumbs;
}
}
return breadcrumbs;
}
@@ -0,0 +1,162 @@
import type { AccountOrg } from '$lib/features/app/types';
import CogIcon from '@lucide/svelte/icons/cog';
import type { Component } from 'svelte';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import UsersIcon from '@lucide/svelte/icons/users';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
title: string; // translation key
url: string;
path: string; // Actual path (e.g., '/settings') for breadcrumb generation
icon?: Component;
isActive?: boolean;
isExpanded?: boolean;
items?: NavItem[]; // for nested items like settings
// Metadata for breadcrumb generation
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms)
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
}
export interface NavItemConfig {
titleKey: string;
path: string;
icon?: Component;
requiresAdmin?: boolean;
items?: NavItemConfig[];
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
nestedRoutes?: NestedRouteConfig[]; // Static nested routes
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
}
export interface NestedRouteConfig {
path: string; // Relative to parent (e.g., 'ask', 'customize-lms')
titleKey: string; // Translation key or plain text
}
// Base navigation configuration structure
const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'org_navigation.dashboard',
path: '',
icon: LayoutDashboardIcon
},
{
titleKey: 'org_navigation.courses',
path: '/courses',
icon: LibraryBigIcon
},
{
titleKey: 'org_navigation.community',
path: '/community',
icon: MessageSquareMoreIcon,
supportsDynamicSegment: true, // Supports /community/[slug]
nestedRoutes: [
{
path: 'ask',
titleKey: 'Ask Question' // Could be translated
}
]
},
{
titleKey: 'org_navigation.audience',
path: '/audience',
icon: UsersIcon
},
{
titleKey: 'org_navigation.setup',
path: '/setup',
icon: Settings2Icon,
requiresAdmin: true
},
{
titleKey: 'org_navigation.settings',
path: '/settings',
icon: CogIcon,
useHashUrl: true, // Use '#' for collapsible parent
items: [
{
titleKey: 'Profile',
path: '/settings'
},
{
titleKey: 'Organization',
path: '/settings?tab=org'
},
{
titleKey: 'Landing Page',
path: '/settings?tab=landingpage'
},
{
titleKey: 'Billing',
path: '/settings?tab=billing'
}
],
nestedRoutes: [
{
path: 'customize-lms',
titleKey: 'Customize LMS'
},
{
path: 'domains',
titleKey: 'Domains'
},
{
path: 'teams',
titleKey: 'Teams'
}
]
}
];
/**
* Get navigation items based on organization context and permissions
*/
export function getOrgNavigationItems(
currentOrgPath: string,
currentOrg: AccountOrg,
isOrgAdmin: boolean | null,
t: (key: string) => string,
pagePathname: string
): NavItem[] {
const items: NavItem[] = [];
for (const config of baseNavConfig) {
// Skip admin-only items if user is not admin
if (config.requiresAdmin && !isOrgAdmin) {
continue;
}
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
const item: NavItem = {
title: t(config.titleKey),
url: config.useHashUrl ? '#' : url,
path: config.path, // Store actual path for breadcrumb generation
icon: config.icon,
isActive: isActive(pagePathname, fullPath),
isExpanded: config.items ? isActive(pagePathname, fullPath) : undefined,
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment
};
// Handle nested items (like settings sub-items)
if (config.items) {
item.items = config.items.map((subConfig) => ({
title: subConfig.titleKey,
url: `${currentOrgPath}${subConfig.path}`,
path: subConfig.path
}));
}
items.push(item);
}
return items;
}