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 @@
export { default as SidebarFooterMenu } from './menu.svelte';
@@ -0,0 +1,141 @@
<script lang="ts">
import { globalStore } from '$lib/utils/store/app';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import RocketIcon from '@lucide/svelte/icons/rocket';
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import ThemeToggle from './theme-toggle.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { logout } from '$lib/utils/functions/logout';
import { profile } from '$lib/utils/store/user';
const sidebar = useSidebar();
</script>
<!--
SNIPPETS
-->
{#snippet avatarblock()}
<Avatar.Root>
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname} />
<Avatar.Fallback class="rounded-lg">{$profile.fullname.slice(0, 2)}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm font-normal leading-tight">
<span class="truncate">{$profile.fullname}</span>
<span class="truncate text-xs">{$profile.email}</span>
</div>
{/snippet}
{#snippet usertrigger()}
<DropdownMenu.Label class="p-0">
<a
href={`${!$globalStore.isOrgSite ? $currentOrgPath : '/lms'}/settings`}
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
>
{@render avatarblock()}
</a>
</DropdownMenu.Label>
{/snippet}
{#snippet themetoggle()}
<DropdownMenu.Label class="p-0 font-normal">
<ThemeToggle />
</DropdownMenu.Label>
{/snippet}
<!--
COMPONENT
-->
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<!-- DROPDOWN TRIGGER -->
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
{@render avatarblock()}
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<!-- DROPDOWN CONTENT -->
<DropdownMenu.Content
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
{@render usertrigger()}
<DropdownMenu.Separator />
{@render themetoggle()}
<DropdownMenu.Separator />
<DropdownMenu.Group>
<div class="cursor-pointer space-y-2">
{#if !$globalStore.isOrgSite}
<div class="space-y-4">
<DropdownMenu.Item class="m-0">
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
<BellPlusIcon size={16} />
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a
href="https://classroomio.com/blog/launch-week"
target="_blank"
class="flex w-full items-center gap-2"
>
<RocketIcon size={16} />
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a
href="https://classroomio.com/contact"
target="_blank"
rel="noopener noreferrer"
class="flex h-full w-full items-center justify-start space-x-2 text-start"
>
<BadgeHelpIcon size={20} />
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
</a>
</DropdownMenu.Item>
</div>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => logout()}>
<span class="flex items-center gap-2">
<LogOutIcon />
<p class="text-sm">{$t('settings.profile.logout')}</p>
</span>
</DropdownMenu.Item>
</div>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -0,0 +1,46 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { MonitorIcon, SunIcon, MoonIcon } from '@lucide/svelte';
import { userPrefersMode, resetMode, setMode } from 'mode-watcher';
type UserPrefersMode = typeof userPrefersMode.current;
const themes: { mode: UserPrefersMode; icon: typeof MonitorIcon }[] = [
{ mode: 'light', icon: SunIcon },
{ mode: 'dark', icon: MoonIcon },
{ mode: 'system', icon: MonitorIcon }
];
const handleThemeChange = (newMode: UserPrefersMode) => {
if (newMode === 'light') {
setMode('light');
} else if (newMode === 'dark') {
setMode('dark');
} else {
resetMode();
}
};
$effect(() => {
console.log('mode.current', userPrefersMode.current);
});
</script>
<div class="flex items-center justify-between gap-8">
<p>Theme</p>
<div class="flex items-center gap-1 rounded-md backdrop-blur-sm">
{#each themes as theme (theme.mode)}
{@const isActive = userPrefersMode.current === theme.mode || userPrefersMode.current === undefined}
<Button
size="icon-sm"
variant={isActive ? 'outline' : 'ghost'}
class={['size-4 rounded-md p-1 transition-all duration-200', isActive && 'shadow-primary/20 shadow-lg']}
title={theme.mode}
onclick={() => handleThemeChange(theme.mode)}
>
<theme.icon class="size-4" />
</Button>
{/each}
</div>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import { currentOrg } from '$lib/utils/store/org';
import { Badge } from '@cio/ui/base/badge';
const planName = $derived($currentOrg.plans?.[0]?.planName || 'FREE');
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
size="sm"
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
>
{#snippet child({ props })}
<a
href="https://classroomio.com?utm_source={$currentOrg.siteName}.classroomio.com"
target="_blank"
rel="noopener noreferrer"
{...props}
>
<Avatar.Root class="ui:flex ui:size-6 ui:items-center ui:justify-center">
<Avatar.Image src={'/logo-192.png'} alt="ClassroomIO logo" />
</Avatar.Root>
<span class="truncate font-normal">ClassroomIO</span>
<Badge variant="outline" class="capitalize">
{planName.toLowerCase()}
</Badge>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -0,0 +1 @@
export { default as OrgSidebar } from './org-sidebar.svelte';
@@ -0,0 +1,64 @@
<script lang="ts">
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
import * as Collapsible from '@cio/ui/base/collapsible';
import * as Sidebar from '@cio/ui/base/sidebar';
import { t } from '$lib/utils/functions/translations';
import { isActive } from '$lib/utils/functions/app';
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { getOrgNavigationItems } from '$lib/features/ui/navigation/org-navigation';
const items = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
const pathWithQuery = $derived(page.url.pathname + page.url.search);
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>{$t('org_navigation.platform')}</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item (item.title)}
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon />
{/if}
<span>{item.title}</span>
{#if item.items}
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{#if item.items}
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, true)}>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
@@ -0,0 +1,34 @@
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import { profile } from '$lib/utils/store/user';
import { orgs } from '$lib/utils/store/org';
import AppLogo from './app-logo.svelte';
import NavMain from './nav-main.svelte';
import { SidebarFooterMenu } from '../footer';
import UpgradeTrigger from './upgrade-trigger.svelte';
import SidebarSkeleton from '../sidebar-skeleton.svelte';
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
</script>
{#if !isOrgLoaded}
<SidebarSkeleton />
{:else}
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<AppLogo />
</Sidebar.Header>
<Sidebar.Content>
<NavMain />
<UpgradeTrigger />
</Sidebar.Content>
<Sidebar.Footer>
<SidebarFooterMenu />
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
{/if}
@@ -0,0 +1,156 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Skeleton } from '@cio/ui/base/skeleton';
import { useSidebar } from '@cio/ui/base/sidebar';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { getShortOrgName } from '$lib/utils/functions/org';
import type { AccountOrg } from '$lib/features/app/types';
import { setTheme } from '$lib/utils/functions/theme';
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
import { ComingSoon } from '$lib/features/ui';
const sidebar = useSidebar();
interface Props {
variant?: 'sidebar' | 'breadcrumb';
}
let { variant = 'sidebar' }: Props = $props();
function onClick(org: AccountOrg) {
if (org.id === $currentOrg.id) return;
localStorage.setItem('classroomio_org_sitename', org.siteName);
currentOrg.set(org);
setTheme(org.theme);
window.location.href = $currentOrgPath;
}
</script>
{#if variant === 'breadcrumb'}
<!-- Breadcrumb context version -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Breadcrumb.Link {...props} href="##" class="flex items-center gap-2">
{#if $currentOrg.name}
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
<ChevronDownIcon class="ml-auto hidden size-4 md:block" />
{:else}
<Skeleton class="h-4 w-24" />
{/if}
</Breadcrumb.Link>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
align="start"
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
{#each $orgs as org (org.name)}
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
<Avatar.Image src={org.avatarUrl} alt={org.name} />
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
</Avatar.Root>
{org.name}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
<PlusIcon class="size-4" />
</div>
<div class="ui:text-muted-foreground ui:font-normal">Add Organization</div>
<ComingSoon />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<!-- Sidebar context version -->
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
>
{#if $currentOrg.name}
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-lg">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-normal">
{$currentOrg.name}
</span>
<span class="truncate text-xs">
{$currentOrg.plans?.[0]?.planName || 'Free'}
</span>
</div>
<ChevronsUpDownIcon class="ml-auto" />
{:else}
<div class="h-[30px] w-[219px]">
<Skeleton class="h-full w-full" />
</div>
{/if}
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
align="start"
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
{#each $orgs as org (org.name)}
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
<Avatar.Image src={org.avatarUrl} alt={org.name} />
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
</Avatar.Root>
{org.name}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
<PlusIcon class="size-4" />
</div>
<div class="ui:text-muted-foreground ui:font-normal">Add Organization</div>
<ComingSoon />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
{/if}
@@ -0,0 +1,50 @@
<script lang="ts">
import * as Sidebar from '@cio/ui/base/sidebar';
import { Button } from '$src/base/button';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { t } from '$lib/utils/functions/translations';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { useSidebar } from '@cio/ui/base/sidebar';
const sidebar = useSidebar();
</script>
{#if $isFreePlan}
<Sidebar.Content>
{#if sidebar.open || sidebar.isMobile}
<div
class="mx-4 mt-2 flex flex-col items-center justify-center gap-4 rounded-md border border-blue-600 px-2 py-6 text-center transition-all ease-in-out hover:border-blue-800"
>
<SparklesIcon class="my-3 size-6" />
<span class="flex flex-col gap-1">
<p class="text-base font-semibold">{$t('org_navigation.early_adopter')}</p>
<p class="text-xs">{$t('org_navigation.unlock')}</p>
</span>
<Button
data-sidebar="upgrade-trigger"
data-slot="upgrade-trigger"
class="font-normal"
type="button"
onclick={openUpgradeModal}
>
<SparklesIcon class="custom size-4" />
{$t('org_navigation.upgrade')}
</Button>
</div>
{:else}
<Button
data-sidebar="upgrade-trigger"
data-slot="upgrade-trigger"
variant="ghost"
size="icon"
class="mx-1 flex w-full items-center justify-center"
type="button"
onclick={openUpgradeModal}
>
<SparklesIcon class="custom size-4" />
<span class="sr-only">{$t('org_navigation.upgrade')}</span>
</Button>
{/if}
</Sidebar.Content>
{/if}
@@ -0,0 +1,49 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="bg-sidebar flex h-[calc(100vh-48px)] w-64 flex-col gap-2 border-r p-2">
<!-- org switcher -->
<div class="flex items-center gap-2 rounded-md p-2">
<Skeleton class="h-8 w-8 rounded-lg" />
<div class="flex flex-1 flex-col gap-1">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-3 w-16" />
</div>
<Skeleton class="h-4 w-4" />
</div>
<!-- nav main -->
<div class="flex flex-col gap-1 py-2">
<Skeleton class="mb-2 h-3 w-16 px-2" />
{#each Array(5) as _, i (i)}
<div class="flex items-center gap-2 rounded-md p-2">
<Skeleton class="h-4 w-4" />
<Skeleton class="h-4 w-full" />
</div>
{/each}
</div>
<!-- spacer -->
<div class="flex-1"></div>
<!-- upgrade banner -->
<div class="mx-4 mb-2 flex flex-col items-center gap-3 rounded-md border p-4">
<Skeleton class="h-16 w-16 rounded-md" />
<div class="flex flex-col items-center gap-2">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-3 w-40" />
</div>
<Skeleton class="h-9 w-24 rounded-md" />
</div>
<!-- nav user -->
<div class="flex items-center gap-2 rounded-md p-2">
<Skeleton class="h-8 w-8 rounded-lg" />
<div class="flex flex-1 flex-col gap-1">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-3 w-20" />
</div>
<Skeleton class="h-4 w-4" />
</div>
</div>