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