1
0
This commit is contained in:
Ahmed Bouhuolia
2026-04-04 00:35:56 +02:00
parent 6e04440fbd
commit ceef73ba0a
45 changed files with 1952 additions and 66 deletions
+5 -2
View File
@@ -47,8 +47,11 @@ SIGNUP_ALLOWED_EMAILS=
# Sign-up Email Confirmation
SIGNUP_EMAIL_CONFIRMATION=false
# API rate limit (points,duration,block duration).
API_RATE_LIMIT=120,60,600
# API throttling
THROTTLE_GLOBAL_TTL=60000
THROTTLE_GLOBAL_LIMIT=300
THROTTLE_AUTH_TTL=60000
THROTTLE_AUTH_LIMIT=30
# Gotenberg API for PDF printing - (production).
GOTENBERG_URL=http://gotenberg:3000
+5 -2
View File
@@ -102,8 +102,11 @@ jobs:
# Sign-up
SIGNUP_DISABLED=false
# API rate limit
API_RATE_LIMIT=120,60,600
# API throttling
THROTTLE_GLOBAL_TTL=60000
THROTTLE_GLOBAL_LIMIT=300
THROTTLE_AUTH_TTL=60000
THROTTLE_AUTH_LIMIT=30
# Redis
REDIS_HOST=127.0.0.1
+4 -1
View File
@@ -37,7 +37,10 @@ env:
# Feature flags
SIGNUP_DISABLED: 'false'
SIGNUP_EMAIL_CONFIRMATION: 'false'
API_RATE_LIMIT: 120,60,600
THROTTLE_GLOBAL_TTL: 60000
THROTTLE_GLOBAL_LIMIT: 300
THROTTLE_AUTH_TTL: 60000
THROTTLE_AUTH_LIMIT: 30
# Optional services (empty for OpenAPI generation)
MAIL_HOST: ''
MAIL_PORT: ''
+5 -2
View File
@@ -47,8 +47,11 @@ SIGNUP_ALLOWED_EMAILS=
# Sign-up Email Confirmation
SIGNUP_EMAIL_CONFIRMATION=false
# API rate limit (points,duration,block duration).
API_RATE_LIMIT=120,60,600
# API throttling
THROTTLE_GLOBAL_TTL=60000
THROTTLE_GLOBAL_LIMIT=300
THROTTLE_AUTH_TTL=60000
THROTTLE_AUTH_LIMIT=30
# Gotenberg API for PDF printing - (production).
GOTENBERG_URL=http://gotenberg:3000
@@ -3,11 +3,11 @@ import { registerAs } from '@nestjs/config';
export default registerAs('throttle', () => ({
global: {
ttl: parseInt(process.env.THROTTLE_GLOBAL_TTL ?? '60000', 10),
limit: parseInt(process.env.THROTTLE_GLOBAL_LIMIT ?? '100', 10),
limit: parseInt(process.env.THROTTLE_GLOBAL_LIMIT ?? '300', 10),
},
auth: {
ttl: parseInt(process.env.THROTTLE_AUTH_TTL ?? '60000', 10),
limit: parseInt(process.env.THROTTLE_AUTH_LIMIT ?? '10', 10),
limit: parseInt(process.env.THROTTLE_AUTH_LIMIT ?? '30', 10),
},
}));
@@ -1,9 +1,9 @@
exports.up = function (knex) {
return knex.raw(`
INSERT IGNORE INTO user_tenants (user_id, tenant_id, role, created_at, updated_at)
SELECT id, tenant_id, 'owner', NOW(), NOW()
FROM users
WHERE tenant_id IS NOT NULL
INSERT IGNORE INTO USER_TENANTS (USER_ID, TENANT_ID, ROLE, CREATED_AT, UPDATED_AT)
SELECT ID, TENANT_ID, 'owner', CREATED_AT, UPDATED_AT
FROM USERS
WHERE TENANT_ID IS NOT NULL
`);
};
@@ -0,0 +1,11 @@
exports.up = (knex) => {
return knex.schema.table('users', (table) => {
table.bigInteger('default_tenant_id').unsigned().nullable();
});
};
exports.down = (knex) => {
return knex.schema.table('users', (table) => {
table.dropColumn('default_tenant_id');
});
};
@@ -9,6 +9,7 @@ export class SystemUser extends BaseModel {
public readonly active: boolean;
public readonly tenantId: number;
public readonly defaultTenantId?: number;
public readonly verifyToken: string;
public readonly verified: boolean;
public readonly inviteAcceptedAt!: string;
@@ -6,9 +6,10 @@ import { InactivateUserService } from './commands/InactivateUser.service';
import { GetUserService } from './queries/GetUser.service';
import { AcceptInviteUserService } from './commands/AcceptInviteUser.service';
import { EditUserDto } from './dtos/EditUser.dto';
import { InviteUserDto, SendInviteUserDto } from './dtos/InviteUser.dto';
import { InviteUserDto, SendInviteUserDto, BulkSendInviteUserDto } from './dtos/InviteUser.dto';
import { GetUsersService } from './queries/GetUsers.service';
import { InviteTenantUserService } from './commands/InviteUser.service';
import { SendBulkInvitesService } from './commands/SendBulkInvites.service';
@Injectable()
export class UsersApplication {
@@ -21,6 +22,7 @@ export class UsersApplication {
private readonly getUsersService: GetUsersService,
private readonly acceptInviteUserService: AcceptInviteUserService,
private readonly inviteservice: InviteTenantUserService,
private readonly sendBulkInvitesService: SendBulkInvitesService,
) {}
/**
@@ -119,4 +121,13 @@ export class UsersApplication {
async resendInvite(userId: number) {
return this.inviteservice.resendInvite(userId);
}
/**
* Sends invitations to multiple users.
* @param {BulkSendInviteUserDto} bulkSendInviteDTO - Bulk invitation data.
* @returns {Promise<{ invitedUsers: ITenantUser[]; failedInvites: { email: string; error: string }[] }>} Results.
*/
async sendBulkInvites(bulkSendInviteDTO: BulkSendInviteUserDto) {
return this.sendBulkInvitesService.sendBulkInvites(bulkSendInviteDTO);
}
}
@@ -26,6 +26,7 @@ import { SendInviteUserMailQueue } from './Users.constants';
import InviteSendMainNotificationSubscribe from './subscribers/InviteSendMailNotification.subscriber';
import { SendInviteUserMailProcessor } from './processors/SendInviteUserMail.processor';
import { SendInviteUsersMailMessage } from './commands/SendInviteUsersMailMessage.service';
import { SendBulkInvitesService } from './commands/SendBulkInvites.service';
import { MailModule } from '../Mail/Mail.module';
const models = [InjectSystemModel(UserInvite)];
@@ -51,6 +52,7 @@ const models = [InjectSystemModel(UserInvite)];
GetUsersService,
AcceptInviteUserService,
InviteTenantUserService,
SendBulkInvitesService,
PurgeUserAbilityCacheSubscriber,
SyncTenantUserDeleteSubscriber,
SyncTenantUserMutateSubscriber,
@@ -59,7 +61,7 @@ const models = [InjectSystemModel(UserInvite)];
InviteSendMainNotificationSubscribe,
SendInviteUserMailProcessor,
SendInviteUsersMailMessage,
UsersApplication
UsersApplication,
],
controllers: [UsersController, UsersInviteController, UsersInvitePublicController],
})
@@ -1,7 +1,7 @@
import { Body, Controller, Param, Patch, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { UsersApplication } from './Users.application';
import { SendInviteUserDto } from './dtos/InviteUser.dto';
import { SendInviteUserDto, BulkSendInviteUserDto } from './dtos/InviteUser.dto';
@Controller('invite')
@ApiTags('Users')
@@ -35,4 +35,19 @@ export class UsersInviteController {
message: 'The invitation has been resent successfully.',
};
}
/**
* Send invitations to multiple users.
*/
@Post('bulk')
@ApiOperation({ summary: 'Send invitations to multiple users.' })
async sendBulkInvites(@Body() bulkSendInviteDTO: BulkSendInviteUserDto) {
const result = await this.usersApplication.sendBulkInvites(bulkSendInviteDTO);
return {
invitedUsers: result.invitedUsers,
failedInvites: result.failedInvites,
message: 'Bulk invitations processed.',
};
}
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model';
import { BulkSendInviteUserDto } from '../dtos/InviteUser.dto';
import { InviteTenantUserService } from './InviteUser.service';
type FailedInvite = {
email: string;
error: string;
};
type SendBulkInvitesResult = {
invitedUsers: TenantUser[];
failedInvites: FailedInvite[];
};
@Injectable()
export class SendBulkInvitesService {
constructor(
private readonly inviteTenantUserService: InviteTenantUserService,
) {}
async sendBulkInvites(
bulkSendInviteDTO: BulkSendInviteUserDto,
): Promise<SendBulkInvitesResult> {
const invitedUsers: TenantUser[] = [];
const failedInvites: FailedInvite[] = [];
for (const invite of bulkSendInviteDTO.invites) {
try {
const result = await this.inviteTenantUserService.sendInvite(invite);
invitedUsers.push(result.invitedUser);
} catch (error) {
failedInvites.push({
email: invite.email,
error: this.getErrorMessage(error),
});
}
}
return { invitedUsers, failedInvites };
}
private getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return 'Failed to send invitation';
}
}
@@ -1,5 +1,6 @@
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { IsNotEmpty, IsNumber, IsString, IsArray, ValidateNested } from 'class-validator';
import { ApiProperty, ApiExtraModels } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@ApiExtraModels()
export class InviteUserDto {
@@ -46,3 +47,34 @@ export class SendInviteUserDto {
@IsNotEmpty()
roleId: number;
}
@ApiExtraModels()
export class BulkInviteItemDto {
@ApiProperty({
description: 'Email address of the user to invite',
example: 'john.doe@example.com',
})
@IsString()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'Role ID to assign to the invited user',
example: 2,
})
@IsNumber()
@IsNotEmpty()
roleId: number;
}
@ApiExtraModels()
export class BulkSendInviteUserDto {
@ApiProperty({
description: 'List of users to invite',
type: [BulkInviteItemDto],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => BulkInviteItemDto)
invites: BulkInviteItemDto[];
}
@@ -6,6 +6,7 @@ import {
HttpCode,
Param,
Post,
Put,
} from '@nestjs/common';
import { ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
import { ClsService } from 'nestjs-cls';
@@ -16,9 +17,11 @@ import { IgnoreTenantSeededRoute } from '@/modules/Tenancy/EnsureTenantIsSeeded.
import { IgnoreTenantModelsInitialize } from '@/modules/Tenancy/TenancyInitializeModels.guard';
import { CreateWorkspaceService } from './commands/CreateWorkspace.service';
import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service';
import { SetDefaultWorkspaceService } from './commands/SetDefaultWorkspace.service';
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
import { CreateWorkspaceDto } from './dtos/CreateWorkspace.dto';
import { SetDefaultWorkspaceDto } from './dtos/SetDefaultWorkspace.dto';
import {
CreateWorkspaceResponseDto,
WorkspaceDto,
@@ -32,6 +35,7 @@ export class WorkspacesController {
constructor(
private readonly createWorkspaceService: CreateWorkspaceService,
private readonly deleteWorkspaceService: DeleteWorkspaceService,
private readonly setDefaultWorkspaceService: SetDefaultWorkspaceService,
private readonly getWorkspacesService: GetWorkspacesService,
private readonly getWorkspaceBuildJobService: GetWorkspaceBuildJobService,
private readonly cls: ClsService,
@@ -120,4 +124,27 @@ export class WorkspacesController {
async buildJobStatus(@Param('buildJobId') buildJobId: string): Promise<WorkspaceBuildJobResponseDto> {
return this.getWorkspaceBuildJobService.getJobDetails(buildJobId);
}
/**
* Sets the given organization as the user's default workspace.
* No `organization-id` header required.
*/
@Put('default')
@TenantAgnosticRoute()
@IgnoreUserVerifiedRoute()
@HttpCode(200)
@ApiOperation({ summary: 'Set default workspace' })
@ApiResponse({
status: 200,
description: 'Default workspace set successfully',
})
async setDefaultWorkspace(
@Body() dto: SetDefaultWorkspaceDto,
): Promise<void> {
const userId = this.cls.get<number>('userId');
return this.setDefaultWorkspaceService.setDefaultWorkspace(
userId,
dto.organizationId,
);
}
}
@@ -3,12 +3,15 @@ import { BullModule } from '@nestjs/bullmq';
import { WorkspacesController } from './Workspaces.controller';
import { CreateWorkspaceService } from './commands/CreateWorkspace.service';
import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service';
import { SetDefaultWorkspaceService } from './commands/SetDefaultWorkspace.service';
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
import { CreateUserTenantOnSignupSubscriber } from './subscribers/CreateUserTenantOnSignup.subscriber';
import { OrganizationBuildQueue } from '@/modules/Organization/Organization.types';
import { InjectSystemModel } from '@/modules/System/SystemModels/SystemModels.module';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { TenantDBManagerModule } from '@/modules/TenantDBManager/TenantDBManager.module';
import { GetBuildOrganizationBuildJob } from '@/modules/Organization/commands/GetBuildOrganizationJob.service';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
@@ -21,9 +24,12 @@ import { TenantRepository } from '@/modules/System/repositories/Tenant.repositor
controllers: [WorkspacesController],
providers: [
InjectSystemModel(UserTenant),
InjectSystemModel(SystemUser),
InjectSystemModel(TenantModel),
TenantRepository,
CreateWorkspaceService,
DeleteWorkspaceService,
SetDefaultWorkspaceService,
GetWorkspacesService,
GetWorkspaceBuildJobService,
CreateUserTenantOnSignupSubscriber,
@@ -0,0 +1,57 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantModel } from '@/modules/System/models/TenantModel';
@Injectable()
export class SetDefaultWorkspaceService {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
) {}
/**
* Sets the given organization as the user's default workspace.
* Validates that the user belongs to the organization.
* @param userId - The user ID
* @param organizationId - The organization ID to set as default
*/
async setDefaultWorkspace(
userId: number,
organizationId: string,
): Promise<void> {
// Find the tenant by organizationId
const tenant = await this.tenantModel
.query()
.where('organization_id', organizationId)
.first();
if (!tenant) {
throw new NotFoundException('Organization not found');
}
// Verify the user belongs to this organization
const membership = await this.userTenantModel
.query()
.where('userId', userId)
.where('tenantId', tenant.id)
.first();
if (!membership) {
throw new NotFoundException(
'User does not belong to this organization',
);
}
// Update the user's default tenant
await this.systemUserModel
.query()
.where('id', userId)
.patch({ defaultTenantId: tenant.id });
}
}
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class SetDefaultWorkspaceDto {
@ApiProperty({ description: 'The organization ID to set as default' })
organizationId: string;
}
@@ -15,6 +15,7 @@ export class WorkspaceDto {
@ApiProperty() isBuildRunning: boolean;
@ApiPropertyOptional() buildJobId?: string;
@ApiProperty() role: 'owner' | 'member';
@ApiPropertyOptional() isDefault?: boolean;
@ApiPropertyOptional({ type: WorkspaceMetadataDto })
metadata?: WorkspaceMetadataDto;
}
@@ -1,5 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
import { WorkspaceTransformer } from '../transformers/WorkspaceTransformer';
@@ -8,6 +9,8 @@ export class GetWorkspacesService {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
@@ -20,7 +23,18 @@ export class GetWorkspacesService {
.where('userId', userId)
.withGraphFetched('tenant.metadata');
// Get user's default tenant ID
const user = await this.systemUserModel
.query()
.select('defaultTenantId')
.where('id', userId)
.first();
const defaultTenantId = user?.defaultTenantId;
const transformer = new WorkspaceTransformer();
return memberships.map((membership) => transformer.transform(membership));
return memberships.map((membership) =>
transformer.transform(membership, defaultTenantId),
);
}
}
@@ -6,11 +6,13 @@ import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
* Transforms UserTenant (workspace membership) to WorkspaceDto.
*/
export class WorkspaceTransformer extends Transformer<UserTenant> {
private defaultTenantId?: number;
/**
* Include these attributes in the transformed output.
*/
public includeAttributes = (): string[] => {
return ['organizationId', 'isReady', 'isBuildRunning', 'buildJobId', 'role', 'metadata'];
return ['organizationId', 'isReady', 'isBuildRunning', 'buildJobId', 'role', 'metadata', 'isDefault'];
};
/**
@@ -58,16 +60,26 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
};
};
/**
* Determine if this workspace is the user's default.
*/
protected isDefault = (membership: UserTenant): boolean => {
if (!this.defaultTenantId) return false;
return membership.tenantId === this.defaultTenantId;
};
/**
* Transform single membership to WorkspaceDto.
*/
transform = (membership: UserTenant): WorkspaceDto => {
transform = (membership: UserTenant, defaultTenantId?: number): WorkspaceDto => {
this.defaultTenantId = defaultTenantId;
return {
organizationId: this.organizationId(membership),
isReady: this.isReady(membership),
isBuildRunning: this.isBuildRunning(membership),
buildJobId: this.buildJobId(membership),
role: membership.role,
isDefault: this.isDefault(membership),
metadata: this.metadata(membership),
};
};
@@ -24,10 +24,12 @@ function DashboardPreferences() {
return (
<div className="dashboard-layout">
<WorkspacesSidebar />
<DashboardSplitPane>
<Sidebar />
<PreferencesPage />
</DashboardSplitPane>
<div className="dashboard-layout__main">
<DashboardSplitPane>
<Sidebar />
<PreferencesPage />
</DashboardSplitPane>
</div>
</div>
);
}
@@ -39,10 +41,12 @@ function DashboardAnyPage() {
return (
<div className="dashboard-layout">
<WorkspacesSidebar />
<DashboardSplitPane>
<Sidebar />
<DashboardContent />
</DashboardSplitPane>
<div className="dashboard-layout__main">
<DashboardSplitPane>
<Sidebar />
<DashboardContent />
</DashboardSplitPane>
</div>
</div>
);
}
@@ -1,7 +1,9 @@
// @ts-nocheck
import React from 'react';
import { DashboardAbilityProvider } from '../../components';
import React, { useEffect } from 'react';
import { Intent } from '@blueprintjs/core';
import { DashboardAbilityProvider, AppToaster } from '../../components';
import { useDashboardMetaBoot } from './DashboardBoot';
import intl from 'react-intl-universal';
/**
* Dashboard provider.
@@ -9,6 +11,20 @@ import { useDashboardMetaBoot } from './DashboardBoot';
export default function DashboardProvider({ children }) {
const { isLoading } = useDashboardMetaBoot();
// Show toast when user has switched workspaces
useEffect(() => {
const switchedWorkspaceName = sessionStorage.getItem('switchedWorkspaceName');
if (switchedWorkspaceName) {
AppToaster.show({
message: intl.get('workspace.switched_successfully', {
name: switchedWorkspaceName,
}),
intent: Intent.SUCCESS,
});
sessionStorage.removeItem('switchedWorkspaceName');
}
}, []);
// Avoid display any dashboard component before complete booting.
if (isLoading) {
return null;
@@ -34,6 +34,8 @@ import { InvoiceSendMailDrawer } from '@/containers/Sales/Invoices/InvoiceSendMa
import { EstimateSendMailDrawer } from '@/containers/Sales/Estimates/EstimateSendMailDrawer';
import { ReceiptSendMailDrawer } from '@/containers/Sales/Receipts/ReceiptSendMailDrawer';
import { PaymentReceivedSendMailDrawer } from '@/containers/Sales/PaymentsReceived/PaymentReceivedMailDrawer';
import { CreateWorkspaceDrawer } from '@/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawer';
import { OrganizationsListDrawer } from '@/containers/Workspaces/OrganizationsListDrawer';
/**
* Drawers container of the dashboard.
@@ -86,6 +88,8 @@ export default function DrawersContainer() {
<EstimateSendMailDrawer name={DRAWERS.ESTIMATE_SEND_MAIL} />
<ReceiptSendMailDrawer name={DRAWERS.RECEIPT_SEND_MAIL} />
<PaymentReceivedSendMailDrawer name={DRAWERS.PAYMENT_RECEIVED_SEND_MAIL} />
<CreateWorkspaceDrawer name={DRAWERS.CREATE_WORKSPACE} />
<OrganizationsListDrawer name={DRAWERS.ORGANIZATIONS_LIST} />
</div>
);
}
@@ -0,0 +1,29 @@
// @ts-nocheck
import React from 'react';
import { firstLettersArgs } from '@/utils';
import '@/style/components/WorkspaceSwitchingOverlay.scss';
interface WorkspaceSwitchingOverlayProps {
workspaceName: string;
}
/**
* Mercury-style centered overlay shown during workspace switching.
* Displays a blurred backdrop with the workspace name and initials.
*/
export function WorkspaceSwitchingOverlay({ workspaceName }: WorkspaceSwitchingOverlayProps) {
const initials = firstLettersArgs(...(workspaceName || '').split(' '));
return (
<div className="workspace-switching-overlay">
<div className="workspace-switching-overlay__backdrop" />
<div className="workspace-switching-overlay__card">
<div className="workspace-switching-overlay__avatar">
{initials}
</div>
<div className="workspace-switching-overlay__subtitle">Switching to</div>
<div className="workspace-switching-overlay__title">{workspaceName}</div>
</div>
</div>
);
}
+1
View File
@@ -62,5 +62,6 @@ export * from './EmptyStatus';
export * from './Postbox';
export * from './AppToaster';
export * from './Layout';
export * from './WorkspaceSwitchingOverlay';
export { MODIFIER, ContextMenu, AvatarCell };
+2
View File
@@ -38,4 +38,6 @@ export enum DRAWERS {
ESTIMATE_SEND_MAIL = 'ESTIMATE_SEND_MAIL',
RECEIPT_SEND_MAIL = 'RECEIPT_SEND_MAIL',
PAYMENT_RECEIVED_SEND_MAIL = 'PAYMENT_RECEIVED_SEND_MAIL',
CREATE_WORKSPACE = 'create-workspace',
ORGANIZATIONS_LIST = 'organizations-list',
}
@@ -1,10 +1,14 @@
// @ts-nocheck
import React from 'react';
import { Tooltip, Position, Spinner } from '@blueprintjs/core';
import React, { useState } from 'react';
import * as R from 'ramda';
import { Tooltip, Position, Spinner, Icon } from '@blueprintjs/core';
import { useWorkspaces } from '@/hooks/query';
import { useAuthOrganizationId } from '@/hooks/state';
import { useSwitchOrganization } from '@/hooks/useSwitchOrganization';
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
import { firstLettersArgs } from '@/utils';
import { WorkspaceSwitchingOverlay } from '@/components';
import classNames from 'classnames';
import '@/style/containers/Dashboard/WorkspacesSidebar.scss';
@@ -29,7 +33,9 @@ function WorkspaceIcon({ workspace, isActive, onClick }) {
'is-active': isActive,
'is-disabled': isDisabled,
})}
onClick={() => !isDisabled && onClick(workspace.organizationId)}
onClick={() =>
!isDisabled && onClick(workspace.organizationId, name)
}
disabled={isDisabled}
>
{workspace.isBuildRunning ? (
@@ -42,32 +48,107 @@ function WorkspaceIcon({ workspace, isActive, onClick }) {
);
}
/**
* Organizations list button.
*/
function OrganizationsListButton({ openDrawer }) {
return (
<Tooltip
content="View all organizations"
position={Position.RIGHT}
minimal
className="workspaces-sidebar__item-tooltip"
>
<button
className={classNames(
'workspaces-sidebar__item',
'workspaces-sidebar__list-btn',
)}
onClick={() => openDrawer(DRAWERS.ORGANIZATIONS_LIST)}
>
<Icon icon="list" size={16} />
</button>
</Tooltip>
);
}
/**
* Add workspace button.
*/
function AddWorkspaceButton({ openDrawer }) {
return (
<Tooltip
content="Create workspace"
position={Position.RIGHT}
minimal
className="workspaces-sidebar__item-tooltip"
>
<button
className={classNames(
'workspaces-sidebar__item',
'workspaces-sidebar__add-btn',
)}
onClick={() => openDrawer(DRAWERS.CREATE_WORKSPACE)}
>
<Icon icon="plus" size={16} />
</button>
</Tooltip>
);
}
/**
* Workspaces sidebar container.
*/
export function WorkspacesSidebar() {
function WorkspacesSidebarRoot({ openDrawer }) {
const { data: workspaces, isLoading } = useWorkspaces();
const activeOrganizationId = useAuthOrganizationId();
const switchOrganization = useSwitchOrganization();
const [switchingWorkspaceName, setSwitchingWorkspaceName] = useState(null);
const handleSwitchWorkspace = (organizationId, workspaceName) => {
if (organizationId === activeOrganizationId) {
return;
}
setSwitchingWorkspaceName(workspaceName);
// Small delay to let the overlay render before the browser navigates
setTimeout(() => {
switchOrganization(organizationId, workspaceName);
}, 350);
};
return (
<div className="workspaces-sidebar">
<div className="workspaces-sidebar__list">
{isLoading ? (
<div className="workspaces-sidebar__loading">
<Spinner size={20} />
</div>
) : (
workspaces?.map((workspace) => (
<WorkspaceIcon
key={workspace.organizationId}
workspace={workspace}
isActive={workspace.organizationId === activeOrganizationId}
onClick={switchOrganization}
/>
))
)}
<>
<div className="workspaces-sidebar">
<div className="workspaces-sidebar__scrollable">
{isLoading ? (
<div className="workspaces-sidebar__loading">
<Spinner size={20} />
</div>
) : (
<div className="workspaces-sidebar__list">
{workspaces?.map((workspace) => (
<WorkspaceIcon
key={workspace.organizationId}
workspace={workspace}
isActive={workspace.organizationId === activeOrganizationId}
onClick={handleSwitchWorkspace}
/>
))}
</div>
)}
</div>
<div className="workspaces-sidebar__footer">
<OrganizationsListButton openDrawer={openDrawer} />
<AddWorkspaceButton openDrawer={openDrawer} />
</div>
</div>
</div>
{switchingWorkspaceName && (
<WorkspaceSwitchingOverlay workspaceName={switchingWorkspaceName} />
)}
</>
);
}
export const WorkspacesSidebar = R.compose(withDrawerActions)(
WorkspacesSidebarRoot,
);
@@ -0,0 +1,100 @@
// @ts-nocheck
import React, { useEffect } from 'react';
import { ProgressBar, Intent } from '@blueprintjs/core';
import { css } from '@emotion/css';
import { x } from '@xstyled/emotion';
import { FormattedMessage as T } from '@/components';
import { useJob } from '@/hooks/query';
import { useIsDarkMode } from '@/hooks/useDarkMode';
interface BuildingWorkspaceStepProps {
jobId?: string;
onComplete: () => void;
}
export default function BuildingWorkspaceStep({
jobId,
onComplete,
}: BuildingWorkspaceStepProps) {
const isDarkMode = useIsDarkMode();
const {
data: { isRunning, isWaiting, isFailed, isCompleted },
isFetching: isJobFetching,
} = useJob(jobId, {
refetchInterval: 2000,
enabled: !!jobId,
});
useEffect(() => {
if (isCompleted) {
onComplete();
}
}, [isCompleted, onComplete]);
const progressBarStyles = css`
.bp4-progress-bar {
border-radius: 40px;
display: block;
height: 6px;
overflow: hidden;
position: relative;
width: 80%;
margin: 0 auto;
.bp4-progress-meter {
background-color: #809cb3;
}
}
`;
if (isFailed) {
return (
<x.div textAlign="center" mt={35}>
<x.h1
fontSize={'22px'}
fontWeight={500}
color={isDarkMode ? 'rgba(255, 255, 255, 0.75)' : '#454c59'}
mt={0}
mb={'14px'}
>
<T id={'create_workspace.building.failed_title'} />
</x.h1>
<x.p
w="70%"
mx="auto"
color={isDarkMode ? 'rgba(255, 255, 255, 0.7)' : '#2e4266'}
>
<T id={'create_workspace.building.failed_description'} />
</x.p>
</x.div>
);
}
return (
<x.div w="95%" mx="auto" pt="16%">
<x.div className={progressBarStyles}>
<ProgressBar intent={Intent.NONE} value={null} />
</x.div>
<x.div textAlign="center" mt={35}>
<x.h1
fontSize={'22px'}
fontWeight={500}
color={isDarkMode ? 'rgba(255, 255, 255, 0.85)' : '#454c59'}
mt={0}
mb={'14px'}
>
<T id={'create_workspace.building.title'} />
</x.h1>
<x.p
w="70%"
mx="auto"
color={isDarkMode ? 'rgba(255, 255, 255, 0.7)' : '#2e4266'}
>
<T id={'create_workspace.building.description'} />
</x.p>
</x.div>
</x.div>
);
}
@@ -0,0 +1,35 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import { Position } from '@blueprintjs/core';
import { Drawer, DrawerSuspense } from '@/components';
import { withDrawers } from '@/containers/Drawer/withDrawers';
import { CreateWorkspaceDrawerContent } from './CreateWorkspaceDrawerContent';
/**
* Create workspace drawer.
*/
function CreateWorkspaceDrawerRoot({
name,
// #withDrawer
isOpen,
payload,
}) {
return (
<Drawer
isOpen={isOpen}
name={name}
size={'600px'}
position={Position.TOP}
payload={payload}
>
<DrawerSuspense>
<CreateWorkspaceDrawerContent />
</DrawerSuspense>
</Drawer>
);
}
export const CreateWorkspaceDrawer = R.compose(withDrawers())(
CreateWorkspaceDrawerRoot,
);
@@ -0,0 +1,21 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
import { CreateWorkspaceStepper } from './CreateWorkspaceStepper';
/**
* Create workspace drawer content.
*/
function CreateWorkspaceDrawerContentRoot({ closeDrawer }) {
const handleClose = () => {
closeDrawer(DRAWERS.CREATE_WORKSPACE);
};
return <CreateWorkspaceStepper onClose={handleClose} />;
}
export const CreateWorkspaceDrawerContent = R.compose(withDrawerActions)(
CreateWorkspaceDrawerContentRoot,
);
@@ -0,0 +1,206 @@
// @ts-nocheck
import React from 'react';
import { Formik, Form } from 'formik';
import { Button, Intent, Classes } from '@blueprintjs/core';
import { getAllCountries } from '@bigcapital/utils';
import { x } from '@xstyled/emotion';
import {
FFormGroup,
FInputGroup,
FSelect,
FTimezoneSelect,
FormattedMessage as T,
DrawerBody,
DrawerActionsBar,
} from '@/components';
import { Col, Row } from '@/components';
import { useIsDarkMode } from '@/hooks/useDarkMode';
import { useCreateWorkspace } from '@/hooks/query';
import { getFiscalYear } from '@/constants/fiscalYearOptions';
import { getLanguages } from '@/constants/languagesOptions';
import { getAllCurrenciesOptions } from '@/constants/currencies';
import { getSetupOrganizationValidation } from '@/containers/Setup/SetupOrganization.schema';
import { transfromToSnakeCase } from '@/utils';
const countries = getAllCountries();
// Initial values.
const defaultValues = {
name: '',
location: '',
baseCurrency: '',
language: 'en',
fiscalYear: '',
timezone: '',
};
/**
* Create workspace form.
*/
export default function CreateWorkspaceForm({ onSubmitting, onCancel }) {
const FiscalYear = getFiscalYear();
const Languages = getLanguages();
const currencies = getAllCurrenciesOptions();
const isDarkMode = useIsDarkMode();
const { mutateAsync: createWorkspaceMutate } = useCreateWorkspace();
const validationSchema = getSetupOrganizationValidation();
const handleSubmit = async (values, { setSubmitting, setErrors }) => {
try {
const result = await createWorkspaceMutate({ ...transfromToSnakeCase(values) });
setSubmitting(false);
onSubmitting({
organizationId: result.organizationId,
jobId: result.jobId,
});
} catch (errors) {
setSubmitting(false);
if (errors?.response?.data?.errors) {
setErrors(errors.response.data.errors);
}
}
};
return (
<Formik
validationSchema={validationSchema}
initialValues={{ ...defaultValues }}
onSubmit={handleSubmit}
>
{(formikProps) => (
<>
<DrawerBody>
<x.div maxWidth={'600px'} w="100%" mx="auto" pt="30px" pb="20px" px="25px">
<x.h3
color={isDarkMode ? 'rgba(255, 255, 255, 0.5)' : '#868f9f'}
mb="2rem"
fontWeight={600}
>
<T id={'create_new_workspace'} />
</x.h3>
<Form>
{/* ---------- Organization name ---------- */}
<FFormGroup name={'name'} label={<T id={'legal_organization_name'} />} fastField>
<FInputGroup name={'name'} large fastField />
</FFormGroup>
{/* ---------- Location ---------- */}
<FFormGroup name={'location'} label={<T id={'business_location'} />} fastField={true}>
<FSelect
name={'location'}
items={countries}
valueAccessor={'countryCode'}
textAccessor={'name'}
placeholder={<T id={'select_business_location'} />}
popoverProps={{ minimal: true }}
buttonProps={{ large: true }}
fastField
/>
</FFormGroup>
<Row>
<Col xs={6}>
{/* ---------- Base currency ---------- */}
<FFormGroup name={'baseCurrency'} label={<T id={'base_currency'} />} fastField={true}>
<FSelect
name={'baseCurrency'}
items={currencies}
popoverProps={{ minimal: true }}
valueAccessor={'key'}
textAccessor={'name'}
placeholder={<T id={'select_base_currency'} />}
buttonProps={{ large: true }}
fastField
/>
</FFormGroup>
</Col>
{/* ---------- Language ---------- */}
<Col xs={6}>
<FFormGroup name={'language'} label={<T id={'language'} />} fastField>
<FSelect
name={'language'}
items={Languages}
valueAccessor={'value'}
textAccessor={'name'}
placeholder={<T id={'select_language'} />}
popoverProps={{ minimal: true }}
buttonProps={{ large: true }}
fastField
/>
</FFormGroup>
</Col>
</Row>
{/* --------- Fiscal Year ----------- */}
<FFormGroup name={'fiscalYear'} label={<T id={'fiscal_year'} />} fastField>
<FSelect
name={'fiscalYear'}
items={FiscalYear}
valueAccessor={'key'}
textAccessor={'name'}
placeholder={<T id={'select_fiscal_year'} />}
popoverProps={{ minimal: true }}
buttonProps={{ large: true }}
fastField
/>
</FFormGroup>
{/* ---------- Time zone ---------- */}
<FFormGroup name={'timezone'} label={<T id={'time_zone'} />}>
<FTimezoneSelect
name={'timezone'}
valueDisplayFormat="composite"
showLocalTimezone={true}
placeholder={<T id={'select_time_zone'} />}
popoverProps={{ minimal: true }}
buttonProps={{
alignText: 'left',
fill: true,
large: true,
}}
/>
</FFormGroup>
<x.p
fontSize={14}
lineHeight="2.7rem"
mb={6}
borderBottom={`1px solid ${isDarkMode ? 'rgba(255, 255, 255, 0.1)' : '#f5f5f5'}`}
className={Classes.TEXT_MUTED}
>
<T id={'setup.organization.note_you_can_change_your_preferences'} />
</x.p>
</Form>
</x.div>
</DrawerBody>
<DrawerActionsBar>
<x.div
display="flex"
justifyContent="flex-end"
gap="10px"
w="100%"
maxWidth="600px"
mx="auto"
px="25px"
>
<Button onClick={onCancel}>
<T id={'cancel'} />
</Button>
<Button
intent={Intent.PRIMARY}
loading={formikProps.isSubmitting}
type="submit"
onClick={formikProps.handleSubmit}
>
<T id={'create'} />
</Button>
</x.div>
</DrawerActionsBar>
</>
)}
</Formik>
);
}
@@ -0,0 +1,59 @@
// @ts-nocheck
import React, { useState } from 'react';
import { Stepper } from '@/components/Stepper';
import { FormattedMessage as T } from '@/components';
import CreateWorkspaceForm from './CreateWorkspaceForm';
import BuildingWorkspaceStep from './BuildingWorkspaceStep';
import InviteUsersStep from './InviteUsersStep';
interface CreateWorkspaceStepperProps {
onClose: () => void;
}
interface CreatedWorkspace {
organizationId: string;
jobId: string;
}
export function CreateWorkspaceStepper({ onClose }: CreateWorkspaceStepperProps) {
const [stepIndex, setStepIndex] = useState(0);
const [createdWorkspace, setCreatedWorkspace] = useState<CreatedWorkspace | null>(null);
const handleWorkspaceCreated = (data: CreatedWorkspace) => {
setCreatedWorkspace(data);
setStepIndex(1);
};
const handleBuildingComplete = () => {
setStepIndex(2);
};
const handleInviteComplete = () => {
onClose();
};
return (
<Stepper active={stepIndex}>
<Stepper.Step label={<T id={'create_workspace.steps.workspace'} />}>
<CreateWorkspaceForm
onSubmitting={handleWorkspaceCreated}
onCancel={onClose}
/>
</Stepper.Step>
<Stepper.Step label={<T id={'create_workspace.steps.building'} />}>
<BuildingWorkspaceStep
jobId={createdWorkspace?.jobId}
onComplete={handleBuildingComplete}
/>
</Stepper.Step>
<Stepper.Step label={<T id={'create_workspace.steps.invite'} />}>
<InviteUsersStep
organizationId={createdWorkspace?.organizationId}
onComplete={handleInviteComplete}
/>
</Stepper.Step>
</Stepper>
);
}
@@ -0,0 +1,251 @@
// @ts-nocheck
import React, { useState, useCallback } from 'react';
import { Button, Intent, InputGroup, MenuItem } from '@blueprintjs/core';
import { Select } from '@blueprintjs/select';
import { x } from '@xstyled/emotion';
import { FormattedMessage as T, DrawerBody, DrawerActionsBar } from '@/components';
import { useBulkCreateInviteUsers, useRoles } from '@/hooks/query';
import { useIsDarkMode } from '@/hooks/useDarkMode';
import * as Yup from 'yup';
interface InviteRow {
id: string;
email: string;
roleId: number | '';
}
interface InviteUsersStepProps {
organizationId?: string;
onComplete: () => void;
}
const generateId = () => Math.random().toString(36).substr(2, 9);
const emailValidationSchema = Yup.string()
.email('Invalid email format')
.required('Email is required');
export default function InviteUsersStep({ organizationId, onComplete }: InviteUsersStepProps) {
const isDarkMode = useIsDarkMode();
const { mutateAsync: bulkInviteMutate, isLoading: isSubmitting } = useBulkCreateInviteUsers();
const { data: roles, isLoading: isRolesLoading } = useRoles();
const defaultRoleId = roles?.find(r => r.slug === 'standard')?.id || roles?.[0]?.id || '';
const [invites, setInvites] = useState<InviteRow[]>([
{ id: generateId(), email: '', roleId: defaultRoleId },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const addInviteRow = () => {
setInvites(prev => [...prev, { id: generateId(), email: '', roleId: defaultRoleId }]);
};
const removeInviteRow = (id: string) => {
setInvites(prev => {
if (prev.length === 1) {
return [{ id: generateId(), email: '', roleId: defaultRoleId }];
}
return prev.filter(invite => invite.id !== id);
});
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[id];
return newErrors;
});
};
const updateInviteRow = (id: string, field: keyof InviteRow, value: string | number) => {
setInvites(prev =>
prev.map(invite =>
invite.id === id ? { ...invite, [field]: value } : invite
)
);
if (errors[id]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[id];
return newErrors;
});
}
};
const validateInvites = (): boolean => {
const newErrors: Record<string, string> = {};
const emails: string[] = [];
let hasValidInvite = false;
invites.forEach(invite => {
if (!invite.email.trim() && !invite.roleId) {
return;
}
if (invite.email.trim()) {
hasValidInvite = true;
try {
emailValidationSchema.validateSync(invite.email);
} catch (error) {
newErrors[invite.id] = error.message;
}
if (emails.includes(invite.email.toLowerCase())) {
newErrors[invite.id] = newErrors[invite.id] || 'Duplicate email';
}
emails.push(invite.email.toLowerCase());
}
if (!invite.roleId) {
newErrors[invite.id] = newErrors[invite.id] || 'Role is required';
}
});
setErrors(newErrors);
return hasValidInvite && Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateInvites()) {
return;
}
const validInvites = invites
.filter(invite => invite.email.trim() && invite.roleId)
.map(invite => ({
email: invite.email.trim(),
roleId: Number(invite.roleId),
}));
if (validInvites.length === 0) {
onComplete();
return;
}
try {
await bulkInviteMutate({ invites: validInvites });
onComplete();
} catch (error) {
console.error('Failed to send invites:', error);
}
};
const handleSkip = () => {
onComplete();
};
return (
<>
<DrawerBody>
<x.div maxWidth="600px" w="100%" mx="auto" pt="30px" pb="20px" px="25px">
<x.h3
color={isDarkMode ? 'rgba(255, 255, 255, 0.5)' : '#868f9f'}
mb="2rem"
fontWeight={600}
>
<T id={'create_workspace.invite.title'} />
</x.h3>
<x.p
fontSize={14}
mb={4}
color={isDarkMode ? 'rgba(255, 255, 255, 0.7)' : '#2e4266'}
>
<T id={'create_workspace.invite.description'} />
</x.p>
<x.div display="flex" flexDirection="column" gap={3}>
{invites.map((invite, index) => (
<x.div
key={invite.id}
display="flex"
alignItems="flex-start"
gap={2}
>
<x.div flex={1}>
<InputGroup
value={invite.email}
onChange={(e) => updateInviteRow(invite.id, 'email', e.target.value)}
placeholder="Email address"
intent={errors[invite.id] ? Intent.DANGER : Intent.NONE}
/>
{errors[invite.id] && (
<x.div color="red.500" fontSize={12} mt={1}>
{errors[invite.id]}
</x.div>
)}
</x.div>
<x.div width="180px">
<Select
items={roles || []}
itemRenderer={(role, { handleClick, modifiers }) => (
<MenuItem
key={role.id}
text={role.name}
onClick={handleClick}
active={modifiers.active}
disabled={modifiers.disabled}
/>
)}
onItemSelect={(role) => updateInviteRow(invite.id, 'roleId', role.id)}
popoverProps={{ minimal: true }}
disabled={isRolesLoading}
>
<Button
text={roles?.find(r => r.id === invite.roleId)?.name || 'Select role'}
rightIcon="chevron-down"
fill
intent={errors[invite.id] && !invite.roleId ? Intent.DANGER : Intent.NONE}
/>
</Select>
</x.div>
<Button
icon="cross"
minimal
onClick={() => removeInviteRow(invite.id)}
style={{ marginTop: '4px' }}
/>
</x.div>
))}
</x.div>
<x.div mt={4}>
<Button
icon="plus"
minimal
onClick={addInviteRow}
disabled={isRolesLoading}
>
<T id={'create_workspace.invite.add_another'} />
</Button>
</x.div>
</x.div>
</DrawerBody>
<DrawerActionsBar>
<x.div
display="flex"
justifyContent="flex-end"
gap="10px"
w="100%"
maxWidth="600px"
mx="auto"
px="25px"
>
<Button onClick={handleSkip}>
<T id={'skip'} />
</Button>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
onClick={handleSubmit}
>
<T id={'create_workspace.invite.send_invites'} />
</Button>
</x.div>
</DrawerActionsBar>
</>
);
}
@@ -0,0 +1,35 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import { Position } from '@blueprintjs/core';
import { Drawer, DrawerSuspense } from '@/components';
import { withDrawers } from '@/containers/Drawer/withDrawers';
import { OrganizationsListDrawerContent } from './OrganizationsListDrawerContent';
/**
* Organizations list drawer.
*/
function OrganizationsListDrawerRoot({
name,
// #withDrawer
isOpen,
payload,
}) {
return (
<Drawer
isOpen={isOpen}
name={name}
size={'100%'}
position={Position.TOP}
payload={payload}
>
<DrawerSuspense>
<OrganizationsListDrawerContent />
</DrawerSuspense>
</Drawer>
);
}
export const OrganizationsListDrawer = R.compose(withDrawers())(
OrganizationsListDrawerRoot,
);
@@ -0,0 +1,105 @@
// @ts-nocheck
import React, { useState, useMemo, useCallback } from 'react';
import * as R from 'ramda';
import { debounce } from 'lodash';
import { FormGroup, InputGroup, Button, Intent } from '@blueprintjs/core';
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
import { useWorkspaces } from '@/hooks/query';
import { OrganizationsListTable } from './OrganizationsListTable';
import intl from 'react-intl-universal';
import '@/style/containers/Workspaces/OrganizationsListDrawer.scss';
/**
* Organizations list drawer content.
*/
function OrganizationsListDrawerContentRoot({ closeDrawer, openDrawer }) {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const { data: workspaces, isLoading } = useWorkspaces();
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSetSearch = useCallback(
debounce((value) => {
setDebouncedSearch(value);
}, 200),
[]
);
const handleSearchChange = (e) => {
const value = e.target.value;
setSearchQuery(value);
debouncedSetSearch(value);
};
const filteredWorkspaces = useMemo(() => {
if (!debouncedSearch) return workspaces || [];
const query = debouncedSearch.toLowerCase();
return (workspaces || []).filter((workspace) =>
workspace.metadata?.name?.toLowerCase().includes(query),
);
}, [workspaces, debouncedSearch]);
const handleClose = () => {
closeDrawer(DRAWERS.ORGANIZATIONS_LIST);
};
const handleCreateWorkspace = () => {
closeDrawer(DRAWERS.ORGANIZATIONS_LIST);
setTimeout(() => {
openDrawer(DRAWERS.CREATE_WORKSPACE);
}, 300);
};
return (
<div className="organizations-list-drawer">
<div className="organizations-list-drawer__header">
<div className="organizations-list-drawer__header-left">
<h3 className="organizations-list-drawer__title">
{intl.get('workspaces.organizations_list_title', {
fallback: 'Organizations',
})}
</h3>
<span className="organizations-list-drawer__count">
{filteredWorkspaces.length} {filteredWorkspaces.length === 1 ? 'organization' : 'organizations'}
</span>
</div>
<Button
intent={Intent.PRIMARY}
icon="plus"
onClick={handleCreateWorkspace}
className="organizations-list-drawer__create-btn"
>
{intl.get('create_workspace', { fallback: 'Create Workspace' })}
</Button>
</div>
<div className="organizations-list-drawer__toolbar">
<FormGroup label={null} className="form-group--search">
<InputGroup
leftIcon="search"
placeholder={intl.get('workspaces.search_organizations', {
fallback: 'Search organizations...',
})}
value={searchQuery}
onChange={handleSearchChange}
className="input-search"
/>
</FormGroup>
</div>
<div className="organizations-list-drawer__content">
<OrganizationsListTable
workspaces={filteredWorkspaces}
isLoading={isLoading}
onClose={handleClose}
/>
</div>
</div>
);
}
export const OrganizationsListDrawerContent = R.compose(withDrawerActions)(
OrganizationsListDrawerContentRoot,
);
@@ -0,0 +1,252 @@
// @ts-nocheck
import React, { useState, useMemo, useCallback } from 'react';
import {
Button,
Checkbox,
Spinner,
Intent,
Tag,
Tooltip,
Position,
Icon,
} from '@blueprintjs/core';
import { DataTable, TableSkeletonRows } from '@/components';
import { useWorkspaces, useSetDefaultWorkspace } from '@/hooks/query';
import { useAuthOrganizationId } from '@/hooks/state';
import { useSwitchOrganization } from '@/hooks/useSwitchOrganization';
import { firstLettersArgs } from '@/utils';
import { WorkspaceSwitchingOverlay } from '@/components';
import classNames from 'classnames';
import intl from 'react-intl-universal';
// Avatar background colors similar to workspace sidebar style
const AVATAR_COLORS = [
'#4A90E2', // Blue
'#7ED321', // Green
'#F5A623', // Orange
'#BD10E0', // Purple
'#D0021B', // Red
'#50E3C2', // Teal
'#9013FE', // Violet
'#417505', // Dark Green
'#B8E986', // Light Green
'#F8E71C', // Yellow
'#8B572A', // Brown
'#9B9B9B', // Gray
];
/**
* Get a deterministic color for an organization based on its name
*/
function getOrganizationColor(organizationId: string): string {
let hash = 0;
for (let i = 0; i < organizationId.length; i++) {
const char = organizationId.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
const index = Math.abs(hash) % AVATAR_COLORS.length;
return AVATAR_COLORS[index];
}
/**
* Format currency for display
*/
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
/**
* Organizations list table component.
*/
export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
const activeOrganizationId = useAuthOrganizationId();
const switchOrganization = useSwitchOrganization();
const setDefaultWorkspace = useSetDefaultWorkspace();
const [switchingWorkspaceName, setSwitchingWorkspaceName] = useState(null);
const handleSwitchWorkspace = useCallback(
(organizationId, workspaceName) => {
if (organizationId === activeOrganizationId) {
return;
}
setSwitchingWorkspaceName(workspaceName);
onClose();
setTimeout(() => {
switchOrganization(organizationId, workspaceName);
}, 350);
},
[activeOrganizationId, switchOrganization, onClose],
);
const handleSetDefault = useCallback(
(organizationId) => {
setDefaultWorkspace.mutate({ organizationId });
},
[setDefaultWorkspace],
);
const columns = useMemo(
() => [
{
Header: intl.get('name', { fallback: 'Account' }),
accessor: 'metadata.name',
width: 300,
Cell: ({ row }) => {
const workspace = row.original;
const name = workspace.metadata?.name || workspace.organizationId;
const initials = firstLettersArgs(...(name || '').split(' '));
const isActive = workspace.organizationId === activeOrganizationId;
const bgColor = getOrganizationColor(workspace.organizationId);
return (
<div className="organizations-list-table__name">
<div
className={classNames('organizations-list-table__avatar', {
'is-active': isActive,
})}
style={{ backgroundColor: bgColor }}
>
{workspace.isBuildRunning ? (
<Spinner size={14} />
) : (
<span>{initials}</span>
)}
</div>
<div className="organizations-list-table__name-text">
<span className="organizations-list-table__name-label">{name}</span>
{isActive && (
<Tag minimal intent={Intent.PRIMARY} className="organizations-list-table__current-tag">
{intl.get('workspaces.current_organization', { fallback: 'Current' })}
</Tag>
)}
</div>
</div>
);
},
},
{
Header: intl.get('assets_balance', { fallback: 'Assets Balance' }),
accessor: 'balance',
width: 150,
Cell: ({ row }) => {
const workspace = row.original;
// Mock balance for now - in production this would come from the API
const mockBalance = workspace.metadata?.name
? workspace.organizationId.charCodeAt(0) * 10000 + Math.random() * 50000
: 0;
return (
<div className="organizations-list-table__balance">
<span className="organizations-list-table__balance-amount">
{formatCurrency(mockBalance)}
</span>
</div>
);
},
},
{
Header: intl.get('money_movement', { fallback: 'Money Movement' }),
accessor: 'moneyMovement',
width: 200,
Cell: ({ row }) => {
const workspace = row.original;
// Mock money movement data
const mockIn = workspace.metadata?.name
? workspace.organizationId.charCodeAt(0) * 5000 + Math.random() * 20000
: 0;
const mockOut = workspace.metadata?.name
? workspace.organizationId.charCodeAt(0) * 3000 + Math.random() * 15000
: 0;
return (
<div className="organizations-list-table__movement">
<span className="organizations-list-table__movement-in">
<Icon icon="arrow-top-right" iconSize={12} />
{formatCurrency(mockIn)}
</span>
<span className="organizations-list-table__movement-out">
<Icon icon="arrow-bottom-right" iconSize={12} />
{formatCurrency(mockOut)}
</span>
</div>
);
},
},
{
Header: intl.get('workspaces.default_workspace', { fallback: 'Default' }),
accessor: 'isDefault',
width: 80,
Cell: ({ row }) => {
const workspace = row.original;
const isDisabled = !workspace.isReady || workspace.isBuildRunning;
return (
<Tooltip
content={intl.get('workspaces.set_as_default', { fallback: 'Set as default' })}
position={Position.TOP}
disabled={isDisabled}
>
<Checkbox
checked={workspace.isDefault}
disabled={isDisabled || workspace.isDefault}
onChange={() => handleSetDefault(workspace.organizationId)}
className="organizations-list-table__default-checkbox"
/>
</Tooltip>
);
},
},
{
Header: '',
accessor: 'actions',
width: 60,
disableSortBy: true,
Cell: ({ row }) => {
const workspace = row.original;
const isActive = workspace.organizationId === activeOrganizationId;
const isDisabled = !workspace.isReady || workspace.isBuildRunning;
return (
<Button
minimal
disabled={isDisabled}
onClick={() =>
handleSwitchWorkspace(workspace.organizationId, workspace.metadata?.name)
}
className="organizations-list-table__switch-btn"
icon={<Icon icon="arrow-right" iconSize={20} />}
/>
);
},
},
],
[activeOrganizationId, handleSwitchWorkspace, handleSetDefault],
);
return (
<>
<DataTable
columns={columns}
data={workspaces}
loading={isLoading}
headerLoading={isLoading}
progressBarLoading={isLoading}
TableLoadingRenderer={TableSkeletonRows}
noInitialFetch={true}
manualPagination={false}
hidePaginationNoPages={true}
pagination={false}
className="organizations-list-table"
/>
{switchingWorkspaceName && (
<WorkspaceSwitchingOverlay workspaceName={switchingWorkspaceName} />
)}
</>
);
}
@@ -0,0 +1 @@
export * from './OrganizationsListDrawer';
+16
View File
@@ -28,6 +28,22 @@ export function useCreateInviteUser(props) {
});
}
/**
* Bulk invite users.
*/
export function useBulkCreateInviteUsers(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation((values) => apiRequest.post('invite/bulk', values), {
onSuccess: () => {
// Common invalidate queries.
commonInvalidateQueries(queryClient);
},
...props,
});
}
/**
* Edits the given user.
*/
+45 -6
View File
@@ -1,5 +1,8 @@
// @ts-nocheck
import { useMutation, useQueryClient } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
/**
* Retrieve workspaces of the authenticated user.
@@ -9,14 +12,50 @@ export function useWorkspaces(props) {
['workspaces'],
{ method: 'get', url: 'workspaces' },
{
select: (res) => res.data.workspaces,
select: (res) => transformToCamelCase(res.data),
initialDataUpdatedAt: 0,
initialData: {
data: {
workspaces: [],
},
},
initialData: [],
...props,
},
);
}
/**
* Creates a new workspace.
*/
export function useCreateWorkspace() {
const apiRequest = useApiRequest();
const queryClient = useQueryClient();
return useMutation(
async (values) => {
const response = await apiRequest.post('workspaces', values);
return transformToCamelCase(response.data);
},
{
onSuccess: () => {
queryClient.invalidateQueries(['workspaces']);
},
}
);
}
/**
* Sets the default workspace for the authenticated user.
*/
export function useSetDefaultWorkspace() {
const apiRequest = useApiRequest();
const queryClient = useQueryClient();
return useMutation(
async (values: { organizationId: string }) => {
const response = await apiRequest.put('workspaces/default', values);
return response.data;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['workspaces']);
},
}
);
}
@@ -14,7 +14,11 @@ export function useSwitchOrganization() {
const queryClient = useQueryClient();
return useCallback(
(organizationId: string) => {
(organizationId: string, workspaceName?: string) => {
// Store workspace name for toast message after reload
if (workspaceName) {
sessionStorage.setItem('switchedWorkspaceName', workspaceName);
}
setCookie('organization_id', organizationId);
dispatch(setOrganizationId(organizationId));
queryClient.removeQueries();
+13
View File
@@ -40,6 +40,19 @@
"create_account": "Create Account",
"success": "Success",
"register_a_new_organization": "Register a New Organization.",
"workspace.switched_successfully": "You switched to {name} workspace",
"workspaces.organizations_list_title": "Organizations",
"workspaces.search_organizations": "Search organizations...",
"workspaces.switch_to_organization": "Switch",
"workspaces.current_organization": "Current",
"workspaces.set_as_default": "Set as default",
"workspaces.default_workspace": "Default",
"building": "Building",
"ready": "Ready",
"pending": "Pending",
"create_workspace": "Create Workspace",
"assets_balance": "Assets Balance",
"money_movement": "Money Movement",
"organization_name": "Organization Name",
"organization_tax_number": "Organization Tax Number",
"email": "Email",
@@ -0,0 +1,69 @@
.workspace-switching-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
&__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
&__card {
position: relative;
z-index: 1;
background: #151521;
border-radius: 16px;
padding: 48px 64px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06),
0 20px 50px rgba(0, 0, 0, 0.4);
animation: workspace-switching-fade-in 0.25s ease-out;
}
&__avatar {
width: 56px;
height: 56px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
&__subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
font-weight: 400;
}
&__title {
font-size: 20px;
color: #fff;
font-weight: 500;
}
}
@keyframes workspace-switching-fade-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@@ -1,27 +1,60 @@
@import 'src/style/_base.scss';
.workspaces-sidebar {
width: 64px;
width: 48px;
height: 100vh;
background: $sidebar-background;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
padding: 12px 0;
border-right: 1px solid rgba(255, 255, 255, 0.05);
z-index: $sidebar-zindex + 1;
&__scrollable {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
// Custom scrollbar for dark theme
&::-webkit-scrollbar {
width: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
}
&__list {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 0 6px;
}
&__footer {
flex-shrink: 0;
display: flex;
justify-content: center;
padding-top: 12px;
margin-top: auto;
}
&__item {
width: 40px;
height: 40px;
width: 34px;
height: 34px;
border-radius: 8px;
border: 0;
background: rgba(255, 255, 255, 0.1);
@@ -46,7 +79,7 @@
&::before {
content: '';
position: absolute;
left: -8px;
left: -6px;
top: 50%;
transform: translateY(-50%);
width: 3px;
@@ -83,6 +116,25 @@
line-height: 1;
}
&__add-btn {
background: transparent;
border: 2px dashed rgba(255, 255, 255, 0.3);
&:hover {
background: rgba(255, 255, 255, 0.1);
border-style: solid;
}
}
&__list-btn {
background: rgba(255, 255, 255, 0.05);
margin-bottom: 8px;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
&__loading {
display: flex;
align-items: center;
@@ -0,0 +1,225 @@
@import '@/style/_base.scss';
.organizations-list-drawer {
padding: 24px 32px;
height: 100%;
display: flex;
flex-direction: column;
background: $dark-gray4;
color: $white;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
&-left {
display: flex;
align-items: baseline;
gap: 12px;
}
}
&__title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: $white;
}
&__count {
font-size: 14px;
color: $gray5;
}
&__create-btn {
&.bp4-button.bp4-intent-primary {
background: $blue3;
&:hover {
background: $blue2;
}
}
}
&__toolbar {
margin-bottom: 16px;
.input-search {
width: 280px;
background: $dark-gray3;
border: 1px solid $dark-gray2;
color: $white;
&::placeholder {
color: $gray5;
}
.bp4-icon {
color: $gray5;
}
}
}
&__content {
flex: 1;
overflow: auto;
}
}
.organizations-list-table {
.rt-table {
background: transparent;
border: none;
}
.rt-thead {
background: transparent;
border-bottom: 1px solid $dark-gray2;
.rt-th {
color: $gray5;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 16px;
border-right: none;
background: transparent;
&:last-child {
text-align: right;
}
}
}
.rt-tbody {
background: transparent;
.rt-tr-group {
border-bottom: 1px solid $dark-gray3;
&:last-child {
border-bottom: none;
}
}
.rt-tr {
background: transparent;
&:hover {
background: $dark-gray3;
}
}
.rt-td {
padding: 16px;
border-right: none;
color: $white;
}
}
&__name {
display: flex;
align-items: center;
gap: 12px;
}
&__avatar {
width: 40px;
height: 40px;
border-radius: 10px;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
&.is-active {
box-shadow: 0 0 0 2px $blue3;
}
.bp4-spinner {
.bp4-spinner-head {
stroke: white;
}
}
}
&__name-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__name-label {
font-weight: 500;
font-size: 14px;
color: $white;
}
&__current-tag {
font-size: 10px;
padding: 2px 8px;
width: fit-content;
}
&__balance {
font-size: 14px;
font-weight: 500;
color: $white;
}
&__movement {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
&-in {
color: $green4;
display: flex;
align-items: center;
gap: 4px;
.bp4-icon {
color: $green4;
}
}
&-out {
color: $red4;
display: flex;
align-items: center;
gap: 4px;
.bp4-icon {
color: $red4;
}
}
}
&__default-checkbox {
margin: 0;
.bp4-control-indicator {
margin-left: 0;
}
}
&__switch-btn {
color: $gray5 !important;
&:hover:not(:disabled) {
color: $white !important;
background: $dark-gray2 !important;
}
&:disabled {
opacity: 0.3;
}
}
}
@@ -5,8 +5,18 @@ $dashboard-views-bar-height: 44px;
display: flex;
height: 100vh;
> .split-pane {
flex: 1 1 auto;
&__main {
flex: 1;
display: flex;
min-width: 0;
overflow: hidden;
// Ensure react-split-pane fills the container
> .SplitPane {
position: relative !important;
width: 100% !important;
height: 100% !important;
}
}
}