feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -4,6 +4,8 @@ import { DEFAULT_CONFIG } from './constants';
import { delay } from './utils';
import { env } from '$env/dynamic/public';
import { hcWithType } from '@cio/api/rpc-types';
import { get } from 'svelte/store';
import { currentOrg } from '$lib/utils/store/org';
class ApiClient {
private config: Required<ApiClientConfig>;
@@ -26,9 +28,17 @@ class ApiClient {
headers.set('Accept', 'application/json');
}
// Add organization ID header if available
const org = get(currentOrg);
if (org?.id) {
headers.set('cio-org-id', org.id);
}
// Handle body serialization and content-type
let requestBody = fetchConfig.body;
if (fetchConfig.body && typeof fetchConfig.body === 'object' && !(fetchConfig.body instanceof FormData)) {
const isFormData = fetchConfig.body instanceof FormData;
if (fetchConfig.body && typeof fetchConfig.body === 'object' && !isFormData) {
// Auto-stringify objects (except FormData)
requestBody = JSON.stringify(fetchConfig.body);
if (!headers.has('Content-Type')) {
@@ -47,7 +57,7 @@ class ApiClient {
const requestInit: RequestInit = {
...fetchConfig,
headers,
headers: isFormData ? undefined : headers,
body: requestBody,
signal: controller.signal
};
@@ -0,0 +1,28 @@
import { env } from '$env/dynamic/private';
/**
* Server-side helper to get API key headers for RPC client calls
* Use this in server-side files (+layout.server.ts, +page.server.ts, +server.ts)
* when making API calls that don't have user session cookies
*
* @returns Headers object with Authorization Bearer token
*
* @example
* ```typescript
* const response = await classroomio.organization.$get(
* { query: { siteName } },
* getApiKeyHeaders()
* );
* ```
*/
export function getApiKeyHeaders(): { headers: { Authorization: string } } {
if (!env.PRIVATE_SERVER_KEY) {
throw new Error('PRIVATE_SERVER_KEY is not configured in environment variables');
}
return {
headers: {
Authorization: `Bearer ${env.PRIVATE_SERVER_KEY}`
}
};
}
@@ -19,6 +19,7 @@ import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
import { supabase } from '$lib/utils/functions/supabase';
import { uploadImage } from '$lib/utils/services/upload';
export async function fetchCourses(profileId, orgId) {
if (!orgId || !profileId) return;
@@ -184,42 +185,10 @@ export async function fetchGroup(groupId: Group['id']) {
};
}
export async function uploadAvatar(courseId: string, avatar: string) {
const filename = `course/${courseId + Date.now()}.webp`;
let logo;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data } = supabase.storage.from('avatars').getPublicUrl(filename);
if (!data.publicUrl) return;
logo = data.publicUrl;
}
return logo;
}
export async function updateCourse(courseId: Course['id'], avatar: string | undefined, course: Partial<Course>) {
export async function updateCourse(courseId: Course['id'], avatar: File | undefined, course: Partial<Course>) {
if (avatar && courseId) {
const filename = `course/${courseId + Date.now()}.webp`;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
if (!response.publicUrl) return;
course.logo = response.publicUrl;
}
const logo = await uploadImage(avatar);
course.logo = logo;
}
await supabase.from('course').update(course).match({ id: courseId });
@@ -1,308 +0,0 @@
import type { CurrentOrg, OrgTeamMember } from '$lib/utils/types/org';
import { currentOrg, orgAudience, orgTeam, orgs } from '$lib/utils/store/org';
import type { OrganizationPlan } from '$lib/utils/types';
import type { PostgrestError } from '@supabase/supabase-js';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { supabase } from '$lib/utils/functions/supabase';
export async function getOrgTeam(orgId: string) {
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
email,
verified,
role_id,
profile(
id,
fullname,
email
)
`
)
.eq('organization_id', orgId)
.neq('role_id', ROLE.STUDENT)
.order('id', { ascending: false })
.returns<
{
id: number;
email: string;
verified: boolean;
role_id: number;
profile: {
id: string;
fullname: string;
email: string;
};
}[]
>();
const team: OrgTeamMember[] = [];
if (data?.length) {
data.forEach((teamMember) => {
team.push({
id: teamMember.id,
email: teamMember?.profile?.email || teamMember.email,
verified: teamMember.verified,
profileId: teamMember?.profile?.id,
fullname: teamMember?.profile?.fullname || '',
role: ROLE_LABEL[teamMember?.role_id] || '',
isAdmin: teamMember?.role_id === ROLE.ADMIN
});
});
orgTeam.set(team);
}
return {
team: get(orgTeam),
error
};
}
export async function getOrganizations(userId: string, isOrgSite?: boolean, orgSiteName?: string) {
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
profile_id,
role_id,
created_at,
organization!organizationmember_organization_id_fkey (
*,
organization_plan(
plan_name,
is_active,
provider,
subscriptionId:payload->id,
customerId:payload->customerId
)
)
`
)
.eq('profile_id', userId)
.order('id', { ascending: false })
.returns<
{
id: string;
profile_id: string;
role_id: string;
created_at: string;
organization: CurrentOrg;
}[]
>();
const orgsArray: CurrentOrg[] = [];
if (Array.isArray(data) && data.length) {
data.forEach((orgMember) => {
orgsArray.push({
...(orgMember?.organization || {}),
memberId: orgMember?.id,
role_id: parseInt(orgMember?.role_id),
shortName: orgMember?.organization?.name?.substring(0, 2)?.toUpperCase() || ''
});
});
orgs.set(orgsArray);
// If this is a student dashboard
if (isOrgSite && orgSiteName) {
const orgData = orgsArray.find((org) => org.siteName === orgSiteName);
if (orgData) {
currentOrg.set(orgData);
}
} else {
// Check if org was last visited in localhost
if (localStorage) {
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
const lastOrg = orgsArray.find((org) => org.siteName === lastOrgSiteName);
if (lastOrg) {
currentOrg.set(lastOrg);
}
}
// Default to setting the first org in the array of orgs
const _currentOrg = get(currentOrg);
if (!_currentOrg.siteName) {
currentOrg.set(orgsArray[0]);
}
}
}
return {
orgs: orgsArray,
currentOrg: get(currentOrg),
error
};
}
export async function getOrgAudience(orgId: string) {
// get all students who are participants in any course belonging to an org
const { data, error } = await supabase
.from('profile')
.select(
`
id,
fullname,
email,
avatar_url,
created_at,
groupmember!inner(
role_id,
group_id:group!inner(
organization_id
)
)
`
)
.eq('groupmember.group.organization_id', orgId)
.eq('groupmember.role_id', ROLE.STUDENT);
const audience = (data || []).map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email,
avatar_url: profile.avatar_url,
date_joined: new Date(profile.created_at).toDateString()
}));
orgAudience.set(audience);
return {
audience: audience,
error
};
}
export async function getCourseBySiteName(siteName: string) {
const { data, error } = await supabase
.from('course')
.select(
`
*,
lessons:lesson(count),
group!inner(
organization!inner(id, name, siteName, avatar_url)
)
`
)
.eq('group.organization.siteName', siteName)
.eq('status', 'ACTIVE')
.eq('is_published', true);
if (error) {
return [];
}
return data;
}
const CURRENT_ORG_QUERY = `
id,
name,
siteName,
avatar_url,
landingpage,
is_restricted,
customization,
theme,
favicon,
customDomain,
isCustomDomainVerified,
customCode,
organization_plan(
plan_name,
is_active
)
`;
export async function getCurrentOrg(siteName: string, justGet = false, isCustomDomain = false) {
let response: { data: CurrentOrg[] | null; error: PostgrestError | null } | null = null;
if (isCustomDomain) {
response = await supabase
.from('organization')
.select(CURRENT_ORG_QUERY)
.eq('customDomain', siteName)
.filter('isCustomDomainVerified', 'eq', true)
.returns<CurrentOrg[]>();
} else {
response = await supabase
.from('organization')
.select(CURRENT_ORG_QUERY)
.eq('siteName', siteName)
.returns<CurrentOrg[]>();
}
const { data, error } = response;
const isDataEmpty = !data?.[0];
if (!justGet && (error || isDataEmpty)) {
console.error('Error getOrganization', error);
return goto('/404');
}
if (!justGet) {
if (isDataEmpty) return;
currentOrg.set(data[0]);
} else if (!isDataEmpty) {
return data[0];
}
}
export async function updateOrgPlan(params: {
supabase: typeof supabase;
subscriptionId: string;
data: OrganizationPlan['payload'];
}) {
return await params.supabase
.from('organization_plan')
.update({
payload: params.data
})
.match({
subscription_id: params.subscriptionId
});
}
export async function createOrgPlan(params: {
orgId: OrganizationPlan['org_id'];
planName: OrganizationPlan['plan_name'];
subscriptionId: OrganizationPlan['subscription_id'];
triggeredBy: OrganizationPlan['triggered_by'];
data: OrganizationPlan['payload'];
supabase: typeof supabase;
}) {
return await params.supabase.from('organization_plan').insert({
activated_at: new Date().toDateString(),
org_id: params.orgId,
triggered_by: params.triggeredBy,
plan_name: params.planName,
is_active: true,
payload: params.data,
subscription_id: params.subscriptionId,
provider: 'polar'
});
}
export async function cancelOrgPlan(params: { subscriptionId: string; data: OrganizationPlan['payload'] }) {
return await supabase
.from('organization_plan')
.update({
is_active: false,
deactivated_at: new Date().toDateString(),
payload: params.data
})
.match({
subscription_id: params.subscriptionId
});
}
@@ -0,0 +1,28 @@
import { classroomio } from './api';
/**
* Uploads an image file to R2 storage and returns the public URL
* @param file - The image file to upload
* @returns The public URL of the uploaded image
* @throws Error if upload fails
*/
export async function uploadImage(file: File): Promise<string> {
try {
const response = await classroomio.media.image.upload.$post({
form: {
file: file
}
});
const data = await response.json();
if (!data.success || !data.url) {
const errorMessage = 'error' in data ? data.error : 'Failed to upload image';
throw new Error(errorMessage);
}
return data.url;
} catch (error) {
console.error('Error uploading image:', error);
throw error;
}
}