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:
@@ -1,4 +1,18 @@
|
||||
export const load = ({ params = { slug: '' } }) => {
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getSubdomain } from '$lib/features/app/layout-setup';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
|
||||
|
||||
export const load = async ({ params, url }) => {
|
||||
const subdomain = getSubdomain(url);
|
||||
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
|
||||
|
||||
// If this is LMS but user is on org site, redirect to LMS
|
||||
if (isOrgSite) {
|
||||
redirect(307, `/lms`);
|
||||
}
|
||||
|
||||
return {
|
||||
orgName: params.slug
|
||||
};
|
||||
|
||||
@@ -1,89 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { isQuizPage } from '$lib/utils/functions/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgs, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { AppHeader } from '$lib/features/ui';
|
||||
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import OrgSideBar from '$lib/components/Sidebar/index.svelte';
|
||||
import { VerifyEmailModal } from '$lib/features/onboarding/components';
|
||||
|
||||
import { OrgSidebar } from '$lib/features/ui/sidebar/org-sidebar/index.js';
|
||||
import AddOrgModal from '$lib/components/Org/AddOrgModal/AddOrgModal.svelte';
|
||||
import VerifyEmailModal from '$lib/components/Org/VerifyEmail/VerifyEmailModal.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
function redirect(siteName: string) {
|
||||
if (!siteName) return;
|
||||
|
||||
const newUrl = page.url.pathname.replace('*', siteName);
|
||||
goto(newUrl + page.url.search);
|
||||
}
|
||||
|
||||
function isActive(pagePath: string, itemPath: string) {
|
||||
const pageLinkItems = pagePath.split('/');
|
||||
const itemLinkItems = itemPath.split('/');
|
||||
|
||||
if (itemLinkItems.length !== pageLinkItems.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pagePath.includes(itemPath);
|
||||
}
|
||||
|
||||
const sidebarData = $derived({
|
||||
teams: $orgs,
|
||||
navMain: [
|
||||
{
|
||||
title: $t('org_navigation.dashboard'),
|
||||
url: '/',
|
||||
icon: LayoutDashboardIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}`)
|
||||
},
|
||||
{
|
||||
title: $t('org_navigation.courses'),
|
||||
url: `/courses`,
|
||||
icon: LibraryBigIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/courses`)
|
||||
},
|
||||
{
|
||||
title: $t('org_navigation.community'),
|
||||
url: `/community`,
|
||||
icon: MessageSquareMoreIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/community`)
|
||||
},
|
||||
{
|
||||
title: $t('org_navigation.audience'),
|
||||
url: `/audience`,
|
||||
icon: UsersIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/audience`)
|
||||
},
|
||||
...(!!$isOrgAdmin
|
||||
? [
|
||||
{
|
||||
title: $t('org_navigation.setup'),
|
||||
url: `/setup`,
|
||||
icon: Settings2Icon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/setup`)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
user: {
|
||||
name: $profile.fullname,
|
||||
email: $profile.email,
|
||||
avatar: $profile.avatar_url
|
||||
}
|
||||
});
|
||||
|
||||
const isSidebarReady = $derived($orgs.length > 0 && $profile.fullname && $profile.email);
|
||||
|
||||
$effect(() => {
|
||||
data.orgName === '*' && redirect($currentOrg.siteName);
|
||||
});
|
||||
@@ -93,76 +29,23 @@
|
||||
|
||||
<VerifyEmailModal />
|
||||
|
||||
<div class="org-root flex w-full items-center justify-between">
|
||||
<div class="org-slot flex w-full items-start bg-white dark:bg-black">
|
||||
{#if !isQuizPage(page.url?.pathname)}
|
||||
{#if isSidebarReady}
|
||||
<OrgSideBar data={sidebarData} />
|
||||
{:else}
|
||||
<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>
|
||||
<Sidebar.Provider>
|
||||
<OrgSidebar />
|
||||
|
||||
<!-- 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>
|
||||
<Sidebar.Inset>
|
||||
<AppHeader />
|
||||
|
||||
<!-- 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 class="container mx-auto flex max-w-6xl flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{#if data.orgName === '*'}
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
</div>
|
||||
<Skeleton class="h-[50vh] w-full rounded-xl" />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex-1">
|
||||
{#if data.orgName === '*'}
|
||||
<Box>Taking you to your organization...</Box>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isQuizPage(page.url?.pathname)}
|
||||
<div class="flex-1">
|
||||
{#if data.orgName === '*'}
|
||||
<Box>Taking you to your organization...</Box>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
import { ActivityCard } from '$lib/components/Analytics';
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
|
||||
import { Grid } from '$lib/components/Org/Settings/Layout';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import WelcomeModal from '$lib/components/WelcomeModal/WelcomeModal.svelte';
|
||||
import { WelcomeModal } from '$lib/features/onboarding/components';
|
||||
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
|
||||
|
||||
let dashAnalytics: OrganisationAnalytics | undefined = $state();
|
||||
@@ -98,9 +98,9 @@
|
||||
|
||||
<WelcomeModal />
|
||||
|
||||
<div class="w-full px-5 py-10 md:mx-auto">
|
||||
<div class="w-full">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h1 class="mb-3 text-2xl dark:text-white">
|
||||
<h1 class="m-0 text-xl font-medium dark:text-white">
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</h1>
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<section class="mx-auto w-full max-w-6xl">
|
||||
<div class="px-5 py-10">
|
||||
<div class="mb-10 flex items-center justify-between">
|
||||
<div class="flex items-end">
|
||||
<div class="flex items-center">
|
||||
<h1 class="m-0 text-2xl md:text-3xl dark:text-white">{$t('audience.title')}</h1>
|
||||
{#if $currentOrgPlan?.plan_name !== PLAN.ENTERPRISE}
|
||||
{#if $currentOrgPlan?.planName !== PLAN.ENTERPRISE}
|
||||
<span class="ml-2">
|
||||
({$orgAudience.length} / {$currentOrgMaxAudience})
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
|
||||
import { Grid } from '$lib/components/Org/Settings/Layout';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -58,20 +58,20 @@
|
||||
icon: BookOpenIcon,
|
||||
title: $t('analytics.enrolled_courses'),
|
||||
description: $t('analytics.enrolled_courses_description'),
|
||||
percentage: userAnalytics?.courses?.length,
|
||||
percentage: userAnalytics?.courses?.length || 0,
|
||||
hidePercentage: true
|
||||
},
|
||||
{
|
||||
icon: ChartLineIcon,
|
||||
title: $t('analytics.overall_course_progress'),
|
||||
description: $t('analytics.overall_course_progress_description'),
|
||||
percentage: userAnalytics?.overallCourseProgress
|
||||
percentage: userAnalytics?.overallCourseProgress || 0
|
||||
},
|
||||
{
|
||||
icon: UnfoldVerticalIcon,
|
||||
title: $t('analytics.total_average_grade'),
|
||||
description: $t('analytics.total_average_grade_description'),
|
||||
percentage: userAnalytics?.overallAverageGrade
|
||||
percentage: userAnalytics?.overallAverageGrade || 0
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,18 +17,16 @@
|
||||
<title>Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="w-full md:mx-auto md:max-w-6xl">
|
||||
<div class="w-full px-5 py-10">
|
||||
<div class="mb-10 flex w-full items-center justify-between">
|
||||
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('community.title')}</h1>
|
||||
{#if $isMobile}
|
||||
<PrimaryButton onClick={askCommunity}>
|
||||
<PlusIcon size={16} />
|
||||
</PrimaryButton>
|
||||
{:else}
|
||||
<PrimaryButton label={$t('community.ask_button')} variant={VARIANTS.CONTAINED_DARK} onClick={askCommunity} />
|
||||
{/if}
|
||||
</div>
|
||||
<Community />
|
||||
<section class="w-full">
|
||||
<div class="mb-10 flex w-full items-center justify-between">
|
||||
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('community.title')}</h1>
|
||||
{#if $isMobile}
|
||||
<PrimaryButton onClick={askCommunity}>
|
||||
<PlusIcon size={16} />
|
||||
</PrimaryButton>
|
||||
{:else}
|
||||
<PrimaryButton label={$t('community.ask_button')} variant={VARIANTS.CONTAINED_DARK} onClick={askCommunity} />
|
||||
{/if}
|
||||
</div>
|
||||
<Community />
|
||||
</section>
|
||||
|
||||
@@ -115,53 +115,51 @@
|
||||
<title>Courses - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="w-full md:mx-auto md:max-w-6xl">
|
||||
<div class="px-2 py-2 md:px-5 md:py-10">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('courses.heading')}</h1>
|
||||
{#if $isMobile}
|
||||
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={openNewCourseModal}>
|
||||
<PlusIcon size={16} />
|
||||
</PrimaryButton>
|
||||
<section class="w-full">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('courses.heading')}</h1>
|
||||
{#if $isMobile}
|
||||
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={openNewCourseModal}>
|
||||
<PlusIcon size={16} />
|
||||
</PrimaryButton>
|
||||
{:else}
|
||||
<PrimaryButton
|
||||
label={$t('courses.heading_button')}
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
isDisabled={!$isOrgAdmin}
|
||||
onClick={openNewCourseModal}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-5 flex flex-row-reverse">
|
||||
<div class="filter-containter flex items-end justify-start gap-2">
|
||||
<div class="relative">
|
||||
<Input type="text" placeholder={$t('courses.search_placeholder')} bind:value={searchValue} class="pl-9" />
|
||||
|
||||
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
</div>
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onClick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<PrimaryButton
|
||||
label={$t('courses.heading_button')}
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
isDisabled={!$isOrgAdmin}
|
||||
onClick={openNewCourseModal}
|
||||
/>
|
||||
<IconButton onClick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-5 flex flex-row-reverse">
|
||||
<div class="filter-containter flex items-end justify-start gap-2">
|
||||
<div class="relative">
|
||||
<Input type="text" placeholder={$t('courses.search_placeholder')} bind:value={searchValue} class="pl-9" />
|
||||
|
||||
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
</div>
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onClick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onClick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewCourseModal />
|
||||
<Courses courses={filteredCourses} />
|
||||
</div>
|
||||
|
||||
<NewCourseModal />
|
||||
<Courses courses={filteredCourses} />
|
||||
</section>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
const setupList = $derived(
|
||||
(data.setup || []).map((item) => {
|
||||
if (item.id === 'profile') {
|
||||
item.is_completed = !$profile.avatar_url.includes('avatars/avatar.png');
|
||||
item.is_completed = !$profile.avatarUrl?.includes('avatars/avatar.png');
|
||||
}
|
||||
return item;
|
||||
})
|
||||
@@ -31,11 +31,11 @@
|
||||
PUBLISH_COURSE: 'publish'
|
||||
};
|
||||
|
||||
const isCompleted = (id) => {
|
||||
const isCompleted = (id: string) => {
|
||||
return setupList?.find((c) => c.id === id)?.is_completed == true;
|
||||
};
|
||||
|
||||
const handleClick = (id) => {
|
||||
const handleClick = (id: string) => {
|
||||
switch (id) {
|
||||
case StepsEnum.CREATE_COURSE:
|
||||
goto(`/org/${$currentOrg.siteName}/courses?create=true`);
|
||||
@@ -86,7 +86,7 @@
|
||||
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('setup.get_started')}</h1>
|
||||
<Chip
|
||||
value={`${completed}/${setupList.length}`}
|
||||
className="text-[10px] font-semibold px-3 !py-1"
|
||||
className="text-[10px] font-semibold px-3 py-1!"
|
||||
shape="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
>
|
||||
<div class={`flex-1 space-y-1 ${list.is_completed ? 'opacity-50' : ''} lg:max-w-[50%]`}>
|
||||
<div class="flex items-center gap-3">
|
||||
<Chip value={i + 1} className={`text-[10px] font-semibold !py-1 `} shape="rounded-full" />
|
||||
<Chip value={i + 1} className="text-[10px] font-semibold py-1" shape="rounded-full" />
|
||||
<p class="text-lg font-medium">{$t(list.title)}</p>
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
@@ -108,7 +108,7 @@
|
||||
<div class="w-full lg:w-[30%]">
|
||||
<PrimaryButton
|
||||
variant={list.is_completed ? VARIANTS.CONTAINED_DARK : VARIANTS.OUTLINED}
|
||||
className="!w-full font-normal text-sm flex items-center gap-2"
|
||||
className="w-full font-normal text-sm flex items-center gap-2"
|
||||
onClick={() => handleClick(list.id)}
|
||||
isDisabled={list.is_completed}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user