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
+28 -221
View File
@@ -1,221 +1,26 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import TextField from '$lib/components/Form/TextField.svelte';
import UserProfileIcon from '$lib/components/Icons/UserProfileIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { profile } from '$lib/utils/store/user';
import { onboardingValidation } from '$lib/utils/functions/validator';
import { supabase } from '$lib/utils/functions/supabase';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { getOrganizations } from '$lib/utils/services/org';
import { onboardingApi } from '$lib/features/onboarding/api/onboarding.svelte';
import { generateSitename } from '$lib/utils/functions/org';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { handleLocaleChange, t } from '$lib/utils/functions/translations';
import { LOCALE } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { GOALS, ONBOARDING_STEPS, SOURCES, DROPDOWN_ITEMS } from '$lib/features/onboarding/utils/constants';
import type { OnboardingField } from '$lib/features/onboarding/utils/types';
import { untrack } from 'svelte';
interface OnboardingField {
fullname?: string;
orgName?: string;
siteName?: string;
goal?: string;
source?: string;
locale?: LOCALE;
metadata?: {};
}
interface Goal {
label: string;
value: string;
}
const maxSteps = 2;
let fields: OnboardingField = $state({
fullname: '',
orgName: '',
siteName: '',
locale: LOCALE.EN
locale: 'en'
});
let errors: OnboardingField = $state({});
let step = $state(1);
let loading = $state(false);
let isSiteNameTouched = $state(false);
let dropdownItems = [
{ id: 'de' as LOCALE, text: 'German' },
{ id: 'en' as LOCALE, text: 'English' },
{ id: 'es' as LOCALE, text: 'Spanish' },
{ id: 'fr' as LOCALE, text: 'French' },
{ id: 'hi' as LOCALE, text: 'Hindi' },
{ id: 'pl' as LOCALE, text: 'Polish' },
{ id: 'pt' as LOCALE, text: 'Portuguese' },
{ id: 'ru' as LOCALE, text: 'Russian' },
{ id: 'vi' as LOCALE, text: 'Vietnamese' },
{ id: 'da' as LOCALE, text: 'Danish' }
];
const progress = $derived(Math.round((step / maxSteps) * 100));
const educatorGoals: Goal[] = [
{
label: $t('onboarding.sell'),
value: 'sell-online'
},
{
label: $t('onboarding.teach'),
value: 'teach-online'
},
{
label: $t('onboarding.employees'),
value: 'employees'
},
{
label: $t('onboarding.customers'),
value: 'customers'
},
{
label: $t('onboarding.expanding'),
value: 'expanding-platform'
}
];
const sources = [
{
label: $t('onboarding.articles'),
value: 'articles'
},
{
label: $t('onboarding.search'),
value: 'search-engine'
},
{
label: $t('onboarding.social'),
value: 'social-media'
},
{
label: $t('onboarding.friends'),
value: 'friends-family'
}
];
async function setMetaData() {
if (!env.PUBLIC_IP_REGISTRY_KEY) return;
const response = await fetch(`https://api.ipregistry.co/?key=${env.PUBLIC_IP_REGISTRY_KEY}`);
const payload = await response.json();
fields.metadata = payload;
}
const handleSubmit = async () => {
loading = true;
// Run validation
console.log(fields);
errors = onboardingValidation(fields, step);
console.log('errors', errors);
if (Object.keys(errors).length || !$profile.id) {
loading = false;
return;
}
if (step === 1) {
// Validate if domain is among our seculeded subdomains
if (blockedSubdomain.includes(fields.siteName || '')) {
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
const { data: org, error } = await supabase
.from('organization')
.insert({
name: fields.orgName,
siteName: fields.siteName
})
.select();
console.log('Create organisation', org);
if (error) {
console.log('Error: create organisation', error);
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
if (Array.isArray(org) && org.length) {
const orgData = org[0];
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: orgData.id,
profile_id: $profile.id,
role_id: 1,
verified: true
})
.select();
console.log('Create organisation member', data);
if (error) {
console.log('Error: create organisation member', error);
errors.siteName = $t('add_org.error_organization');
// Delete organization so it can be recreated.
await supabase.from('organization').delete().match({ siteName: fields.siteName });
loading = false;
return;
}
await getOrganizations($profile.id);
}
// client
}
if (step === maxSteps) {
// Submit filled
// Set extra metadata based off location
await setMetaData();
await supabase
.from('profile')
.update({
...fields,
orgName: undefined,
siteName: undefined
})
.match({ id: $profile.id });
loading = false;
if (fields.fullname) {
$profile.fullname = fields.fullname;
}
if (fields.locale) {
$profile.locale = fields.locale;
if (fields.locale !== LOCALE.EN) {
handleLocaleChange(fields.locale);
}
}
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
to: $profile.email,
profileId: $profile.id,
fullname: $profile.fullname,
orgSiteName: fields.siteName
});
const welcomePopup = `${$profile.is_email_verified}`;
return goto(`/org/${fields.siteName}?welcomePopup=${welcomePopup}`);
}
// Move to next step
step = step + 1;
loading = false;
};
const progress = $derived(Math.round((onboardingApi.step / Object.keys(ONBOARDING_STEPS).length) * 100));
function updateSiteName(sname?: string) {
if (!sname) return;
@@ -224,6 +29,7 @@
fields.siteName = generateSitename(sname);
});
}
$effect(() => {
updateSiteName(fields.siteName);
});
@@ -238,13 +44,14 @@
?.replace(/[^a-zA-Z0-9-]/g, '');
});
}
$effect(() => {
setOrgSiteName(fields.orgName, isSiteNameTouched);
});
</script>
{#if $profile.id}
<div class="flex min-h-screen w-full justify-center">
<div class="flex min-h-screen w-full justify-center dark:bg-neutral-900">
<div class="flex w-9/12 max-w-md flex-col items-center justify-center">
<!-- Header With Logo -->
<div class="flex flex-col items-center">
@@ -257,13 +64,13 @@
<div
class="mb-6 flex w-64 items-center justify-center rounded-2xl border border-gray-300 bg-gray-100 py-6 dark:bg-neutral-800"
>
<UserProfileIcon size={16} />
<UserProfileIcon />
<p class="ml-2 text-sm dark:text-white">{$profile.email}</p>
</div>
</div>
<div class="form-container w-full overflow-y-auto px-2">
{#if step === 1}
{#if onboardingApi.step === ONBOARDING_STEPS.ORG_SETUP}
<!-- Name/Organization Question -->
<div id="role-question" class="mb-6 flex flex-col items-start">
<!-- Full name -->
@@ -275,7 +82,7 @@
placeholder="e.g Joke Silva"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.fullname}
errorMessage={onboardingApi.errors.fullname}
isRequired
/>
@@ -288,7 +95,7 @@
placeholder="e.g Education For All"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.orgName}
errorMessage={onboardingApi.errors.orgName}
isRequired
/>
@@ -302,7 +109,7 @@
placeholder="e.g edforall"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.siteName}
errorMessage={onboardingApi.errors.siteName}
onChange={() => {
isSiteNameTouched = true;
}}
@@ -320,16 +127,16 @@
</label>
<!-- Loop through Goals -->
{#each educatorGoals as goal}
{#each GOALS as goal}
<label class="mb-1 inline-flex w-full items-center font-light dark:text-white">
<input type="radio" bind:group={fields.goal} name="goal" value={goal.value} class="mr-2" />
{goal.label}
{$t(goal.label)}
</label>
{/each}
<!-- Goal: Error message -->
{#if errors.goal}
{#if onboardingApi.errors.goal}
<p class="text-sm text-red-500">
{errors.goal}
{onboardingApi.errors.goal}
</p>
{/if}
</div>
@@ -341,16 +148,16 @@
</label>
<!-- Loop through Goals -->
{#each sources as source}
{#each SOURCES as source}
<label class="mb-1 inline-flex w-full items-center font-light dark:text-white">
<input type="radio" bind:group={fields.source} name="source" value={source.value} class="mr-2" />
{source.label}
{$t(source.label)}
</label>
{/each}
<!-- Goal: Error message -->
{#if errors.source}
{#if onboardingApi.errors.source}
<p class="text-sm text-red-500">
{errors.source}
{onboardingApi.errors.source}
</p>
{/if}
</div>
@@ -360,10 +167,10 @@
<span class="dark:text-white">{$t('content.toggle_label')}: </span>
<Select.Root type="single" bind:value={fields.locale}>
<Select.Trigger class="w-full">
<p>{fields.locale}</p>
<p>{DROPDOWN_ITEMS.find((item) => item.id === fields.locale)?.text}</p>
</Select.Trigger>
<Select.Content>
{#each dropdownItems as item}
{#each DROPDOWN_ITEMS as item}
<Select.Item value={item.id}>{item.text}</Select.Item>
{/each}
</Select.Content>
@@ -381,12 +188,12 @@
</div>
<div class="flex">
{#if step > 1}
{#if onboardingApi.step > ONBOARDING_STEPS.ORG_SETUP}
<PrimaryButton
label={$t('onboarding.back')}
variant={VARIANTS.NONE}
className="py-3 px-6 mr-2 text-primary-700"
onClick={() => (step = step - 1)}
onClick={() => (onboardingApi.step = ONBOARDING_STEPS.ORG_SETUP)}
/>
{/if}
<PrimaryButton
@@ -394,8 +201,8 @@
variant={VARIANTS.CONTAINED}
type="button"
className="px-5 py-3"
isLoading={loading}
onClick={handleSubmit}
isLoading={onboardingApi.isLoading}
onClick={() => onboardingApi.submit(fields)}
/>
</div>
</div>