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
@@ -0,0 +1,77 @@
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan(params: TCreateOrgPlan) {
try {
const response = await classroomio.organization.plan.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error creating org plan (server):', error);
return null;
}
}
/**
* Updates an organization plan (server-side)
* @param params Update parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async updateOrgPlan(params: TUpdateOrgPlan) {
try {
const response = await classroomio.organization.plan.$put(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error updating org plan (server):', error);
return null;
}
}
/**
* Cancels an organization plan (server-side)
* @param params Cancel parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async cancelOrgPlan(params: TCancelOrgPlan) {
try {
const response = await classroomio.organization.plan.cancel.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error canceling org plan (server):', error);
return null;
}
}
}
@@ -1,9 +1,9 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { TNewOrganizationPlan } from '@cio/db/types';
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
/**
* API class for organization plan operations
* API class for organization plan operations (client-side)
* For server-side operations, use OrgPlanApiServer from './org-plan.server.ts'
*/
class OrgPlanApi extends BaseApi {
/**
@@ -11,7 +11,7 @@ class OrgPlanApi extends BaseApi {
* @param params Organization plan creation parameters
* @returns Created organization plan
*/
async createOrgPlan(params: TNewOrganizationPlan) {
async createOrgPlan(params: TCreateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$post({ json: params }),
logContext: 'creating organization plan'
@@ -23,7 +23,7 @@ class OrgPlanApi extends BaseApi {
* @param params Update parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async updateOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async updateOrgPlan(params: TUpdateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$put({ json: params }),
logContext: 'updating organization plan'
@@ -35,7 +35,7 @@ class OrgPlanApi extends BaseApi {
* @param params Cancel parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async cancelOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async cancelOrgPlan(params: TCancelOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }),
logContext: 'canceling organization plan'
@@ -0,0 +1,57 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName(siteName: string) {
try {
const response = await classroomio.organization.$get(
{
query: { siteName }
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
} catch (error) {
console.error('Error fetching organization by siteName (server):', error);
return null;
}
}
/**
* Gets organizations by custom domain (server-side)
* @param customDomain Custom domain
* @param isCustomDomainVerified Whether the domain is verified
* @returns Array of organizations
*/
static async getOrgsByCustomDomain(customDomain: string, isCustomDomainVerified = true) {
try {
const response = await classroomio.organization.$get(
{
query: {
customDomain,
isCustomDomainVerified
}
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data ? data.data : [];
} catch (error) {
console.error('Error fetching organizations by custom domain (server):', error);
return [];
}
}
}
@@ -1,20 +1,45 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CoursesByOrganizationSiteName,
DeleteTeamResponse,
GetAudienceResponse,
GetCoursesBySiteNameResponse,
InviteTeamResponse,
OrganizationAudience,
OrganizationTeamMembers
} from '../utils/types';
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
import { currentOrg, orgs } from '$lib/utils/store/org';
import type { AccountOrg } from '$lib/features/app/types';
import type { GetTeamResponse } from '../utils/types';
import type { TGetOrganizations } from '@cio/utils/validation/organization';
import { orgs } from '$lib/utils/store/org';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import type { UpdateOrganizationRequestType } from './types';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { resolve } from '$app/paths';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TOrgUpdateForm {
name?: string;
avatar?: string | File | undefined;
theme?: string;
landingpage?: AccountOrg['landingpage'];
siteName?: string;
customDomain?: string | null;
isCustomDomainVerified?: boolean;
customization?: AccountOrg['customization'];
}
/**
* API class for organization operations
*/
class OrgApi extends BaseApi {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
orgSiteCourses = $state<CoursesByOrganizationSiteName>([]);
@@ -32,7 +57,16 @@ class OrgApi extends BaseApi {
}),
logContext: 'fetching organization team',
onSuccess: (response) => {
this.teamMembers = response.data;
// Map API response to include role and isAdmin directly in the API layer
const mappedTeam = response.data.map((member) => ({
...member,
role: ROLE_LABEL[member.roleId] || '',
isAdmin: member.roleId === ROLE.ADMIN,
verified: member.verified ?? false
}));
// Update both API state and store
this.teamMembers = mappedTeam;
}
});
}
@@ -42,7 +76,9 @@ class OrgApi extends BaseApi {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId: string) {
async getOrgAudience(orgId?: string) {
if (!orgId) return;
return this.execute<GetAudienceResponse>({
requestFn: () =>
classroomio.organization[':orgId'].audience.$get({
@@ -103,6 +139,186 @@ class OrgApi extends BaseApi {
return result;
}
/**
* Creates a new organization with the current user as owner
* @param fields Organization creation data (name, siteName)
*/
async create(fields: TCreateOrganization) {
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
const { organization, organizations } = response.data;
// Update stores
orgs.set(organizations);
const createdOrg = organizations.find((org) => org.id === organization.id);
if (createdOrg) {
currentOrg.set(createdOrg);
}
snackbar.success('snackbar.success_update');
goto(resolve(`/org/${organization.siteName}`, {}));
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
// Field-specific error automatically mapped to this.errors[field]
this.errors[result.field as string] = result.error;
} else if ('error' in result) {
this.errors.general = result.error;
}
}
});
}
/**
* Updates an organization
* @param orgId Organization ID
* @param fields Organization update data (name, avatar, theme, landingpage)
*/
async update(orgId: string, fields: TOrgUpdateForm) {
const result = ZUpdateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
this.isLoading = true;
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await uploadImage(fields.avatar);
} else if (typeof fields.avatar === 'string') {
avatarUrl = fields.avatar;
}
// Build update payload
fields.avatar = undefined;
const updates: TUpdateOrganization = {
...fields,
avatarUrl
};
await this.execute<UpdateOrganizationRequestType>({
requestFn: () =>
classroomio.organization[':orgId'].$put({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: updates
}),
logContext: 'updating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
orgs.update((_orgs) =>
_orgs.map((org) => {
if (org.id === orgId) {
return { ...org, ...response.data };
}
return org;
})
);
const currentOrgData = get(currentOrg);
if (currentOrgData.id === orgId) {
currentOrg.update((org) => ({
...org,
...response.data
}));
}
snackbar.success('snackbar.course_settings.success.update_successful');
this.success = true;
this.errors = {};
},
onError: (error) => {
console.error('Error updating organization:', error);
const message = error instanceof Error ? error.message : `${error}`;
this.errors.general = message;
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
});
}
/**
* Invites team members to the organization
* @param orgId Organization ID
* @param emails Array of email addresses
* @param roleId Role ID (ADMIN or TUTOR)
*/
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
return this.execute<InviteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team.invite.$post({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: { emails, roleId }
}),
logContext: 'inviting team members',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
this.errors[result.field as string] = result.error;
}
}
});
}
/**
* Removes a team member from the organization
* @param orgId Organization ID
* @param memberId Member ID to remove
*/
async removeTeamMember(orgId: string, memberId: number) {
return this.execute<DeleteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team[':memberId'].$delete({
param: { orgId, memberId: memberId.toString() }
}),
logContext: 'removing team member',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,5 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type UpdateOrganizationRequestType = (typeof classroomio.organization)[':orgId']['$put'];
export type UpdateOrganizationResponse = InferResponseType<UpdateOrganizationRequestType>;
export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { success: true }>;
@@ -5,7 +5,16 @@ export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team'
export type OrganizationTeamResponse = InferResponseType<GetTeamResponse> | null;
export type OrganizationTeamSuccess = Extract<InferResponseType<GetTeamResponse>, { success: true }>;
export type OrganizationTeamMembers = OrganizationTeamSuccess['data'];
export type OrganizationTeamMembersRaw = OrganizationTeamSuccess['data'];
// Mapped team members with role and isAdmin (mapped in API layer)
export type OrganizationTeamMembers = Array<
OrganizationTeamMembersRaw[number] & {
role: string;
isAdmin: boolean;
verified: boolean;
}
>;
// Audience types
export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get'];
@@ -23,3 +32,10 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
>;
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamResponse = (typeof classroomio.organization)[':orgId']['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamResponse>, { success: true }>;