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
@@ -16,11 +16,12 @@ export const ROUTE = {
ASK: '/ask',
FORGOT: '/forgot',
RESET: '/reset',
LOGOUT: '/logout'
LOGOUT: '/logout',
AUTH_FAILED: '/auth-failed'
};
export const PUBLIC_ROUTES = [
// `^${ROUTE.HOME}$`,
`^${ROUTE.HOME}$`,
ROUTE.LOGIN,
ROUTE.SIGN_UP,
`^${ROUTE.INVITE_TEACHER}/.*`,
@@ -28,7 +29,8 @@ export const PUBLIC_ROUTES = [
ROUTE.RESET,
`^${ROUTE.PAGES}/.*`,
`^${ROUTE.COURSE}/.*`,
'/404'
'/404',
ROUTE.AUTH_FAILED
];
export const PUBLIC_API_ROUTES = [
+26 -7
View File
@@ -28,17 +28,36 @@ export function isLMSPage(path) {
return /lms[/a-z 0-9 -]*/.test(path);
}
export function isActive(pagePath: string, itemPath: string, exact: boolean = false) {
const pageLinkItems = pagePath.split('/');
const itemLinkItems = itemPath.split('/');
export function isActive(pagePath: string, itemPath: string, matchPattern?: string, exact: boolean = false) {
// If a regex pattern is provided, use it for matching
if (matchPattern) {
try {
const regex = new RegExp(matchPattern);
return regex.test(pagePath);
} catch (error) {
console.warn('Invalid regex pattern in isActive:', matchPattern, error);
// Fall through to default matching logic
}
}
// Normalize paths by removing trailing slashes (except root)
const normalizedPagePath = pagePath === '/' ? '/' : pagePath.replace(/\/$/, '');
const normalizedItemPath = itemPath === '/' ? '/' : itemPath.replace(/\/$/, '');
if (exact) {
return pagePath === itemPath;
return normalizedPagePath === normalizedItemPath;
}
if (itemLinkItems.length !== pageLinkItems.length) {
return false;
// Exact match
if (normalizedPagePath === normalizedItemPath) {
return true;
}
return pagePath.includes(itemPath);
// Check if pagePath is a nested route of itemPath
// e.g., /org/[slug]/courses matches /org/[slug]/courses/123
// We need to ensure we match at path segment boundaries
const pagePathWithSlash = normalizedPagePath + '/';
const itemPathWithSlash = normalizedItemPath + '/';
return pagePathWithSlash.startsWith(itemPathWithSlash);
}
@@ -1,5 +1,3 @@
import { supabase } from '$lib/utils/functions/supabase';
export const sanitizeDomain = (domain: string) => {
return domain
.trim()
@@ -9,15 +7,11 @@ export const sanitizeDomain = (domain: string) => {
};
export async function sendDomainRequest(key: string, domain: string): Promise<Response> {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token || '';
return fetch('/api/domain', {
method: 'POST',
body: JSON.stringify({ params: { key, domain } }),
headers: {
'Content-Type': 'application/json',
Authorization: `${accessToken}`
'Content-Type': 'application/json'
}
});
}
@@ -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;
}
}
+5 -8
View File
@@ -1,8 +1,8 @@
import type { OrgAudience, OrgTeamMember } from '../types/org';
import { browser, dev } from '$app/environment';
import { derived, writable } from 'svelte/store';
import type { AccountOrg } from '$lib/features/app/types';
import type { OrgTeamMember } from '../types/org';
import { PLAN } from '@cio/utils/plans';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { ROLE } from '@cio/utils/constants';
@@ -10,7 +10,9 @@ import { STEPS } from '../constants/quiz';
import type { UserLessonDataType } from '$lib/utils/types';
import type { Writable } from 'svelte/store';
export const defaultCurrentOrgState: AccountOrg = {
export const orgs = writable<AccountOrg[]>([]);
export const currentOrg: Writable<AccountOrg> = writable({
avatarUrl: '',
createdAt: '',
customCode: '',
@@ -31,12 +33,7 @@ export const defaultCurrentOrgState: AccountOrg = {
settings: {},
siteName: '',
theme: ''
};
export const orgs = writable<AccountOrg[]>([]);
export const currentOrg: Writable<AccountOrg> = writable(defaultCurrentOrgState);
export const orgAudience = writable<OrgAudience[]>([]);
});
export const orgTeam = writable<OrgTeamMember[]>([]);
export const isOrgAdmin = derived(currentOrg, ($currentOrg) => {
if ($currentOrg.roleId === 0) return null;
@@ -120,7 +120,8 @@
"email": "Email",
"confirm": "Confirm",
"cancel": "Cancel",
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email."
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email.",
"email_change_verification_sent": "A verification email has been sent to your new email address. Please check your inbox to verify your new email address."
},
"update_profile": "Update Profile",
"logout": "Log out"
@@ -308,7 +309,13 @@
},
"setup": {
"your_checklist": "Your Checklist",
"get_started": "Get Started",
"description": "Complete these steps to set up your learning platform",
"progress": "Progress",
"completed": "completed",
"done": "Done",
"todo": "Todo",
"personal_profile": {
"title": "Upload a profile picture and update username",
"desc": "Personalize and add a human touch making interactions more personal and memorable",
@@ -1379,6 +1386,7 @@
"success_delete": "Deleted successfully",
"something": "Something went wrong - Please try later",
"update": "Update",
"update_failed": "Update failed",
"poll": {
"error": {
-42
View File
@@ -74,48 +74,6 @@ export interface OrgLandingPageJson {
};
}
export interface OrgCustomization {
dashboard: {
community: boolean;
exercise: boolean;
bannerImage: string;
bannerText: string;
};
course: {
newsfeed: boolean;
grading: boolean;
};
apps: {
poll: boolean;
comments: boolean;
};
}
export interface CurrentOrg {
id: string;
name: string;
shortName: string;
siteName: string;
avatar_url: string;
memberId: string;
role_id: number;
landingpage: OrgLandingPageJson;
customization: OrgCustomization;
theme: string;
organization_plan: {
provider: 'polar' | 'lmz';
subscriptionId: string;
customerId: string;
plan_name: string;
is_active: boolean;
}[];
is_restricted: boolean;
customDomain?: string;
isCustomDomainVerified?: boolean;
customCode?: string;
favicon?: string;
}
export interface OrgTeamMember {
id: number;
email: string;