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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user