From ceef73ba0a1c40a68893210ceba281f504ed3555 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 4 Apr 2026 00:35:56 +0200 Subject: [PATCH] wip --- .env.example | 7 +- .github/workflows/e2e.yml | 7 +- .github/workflows/generate-openapi.yml | 5 +- packages/server/.env.example | 7 +- packages/server/src/common/config/throttle.ts | 4 +- ...000002_backfill_user_tenants_from_users.js | 8 +- ...60403000001_add_default_tenant_to_users.js | 11 + .../src/modules/System/models/SystemUser.ts | 1 + .../modules/UsersModule/Users.application.ts | 13 +- .../src/modules/UsersModule/Users.module.ts | 4 +- .../UsersModule/UsersInvite.controller.ts | 17 +- .../commands/SendBulkInvites.service.ts | 49 ++++ .../UsersModule/dtos/InviteUser.dto.ts | 34 ++- .../ee/Workspaces/Workspaces.controller.ts | 27 ++ .../ee/Workspaces/Workspaces.module.ts | 6 + .../commands/SetDefaultWorkspace.service.ts | 57 ++++ .../dtos/SetDefaultWorkspace.dto.ts | 6 + .../Workspaces/dtos/WorkspaceResponse.dto.ts | 1 + .../queries/GetWorkspaces.service.ts | 16 +- .../transformers/WorkspaceTransformer.ts | 16 +- .../src/components/Dashboard/Dashboard.tsx | 20 +- .../Dashboard/DashboardProvider.tsx | 20 +- .../src/components/DrawersContainer.tsx | 4 + .../components/WorkspaceSwitchingOverlay.tsx | 29 ++ packages/webapp/src/components/index.tsx | 1 + packages/webapp/src/constants/drawers.ts | 2 + .../WorkspacesSidebar/WorkspacesSidebar.tsx | 123 +++++++-- .../BuildingWorkspaceStep.tsx | 100 +++++++ .../CreateWorkspaceDrawer.tsx | 35 +++ .../CreateWorkspaceDrawerContent.tsx | 21 ++ .../CreateWorkspaceForm.tsx | 206 ++++++++++++++ .../CreateWorkspaceStepper.tsx | 59 ++++ .../CreateWorkspaceDrawer/InviteUsersStep.tsx | 251 +++++++++++++++++ .../OrganizationsListDrawer.tsx | 35 +++ .../OrganizationsListDrawerContent.tsx | 105 ++++++++ .../OrganizationsListTable.tsx | 252 ++++++++++++++++++ .../OrganizationsListDrawer/index.ts | 1 + packages/webapp/src/hooks/query/users.tsx | 16 ++ .../webapp/src/hooks/query/workspaces.tsx | 51 +++- .../src/hooks/useSwitchOrganization.tsx | 6 +- packages/webapp/src/lang/en/index.json | 13 + .../components/WorkspaceSwitchingOverlay.scss | 69 +++++ .../Dashboard/WorkspacesSidebar.scss | 64 ++++- .../Workspaces/OrganizationsListDrawer.scss | 225 ++++++++++++++++ .../src/style/pages/Dashboard/Dashboard.scss | 14 +- 45 files changed, 1952 insertions(+), 66 deletions(-) create mode 100644 packages/server/src/database/system/migrations/20260403000001_add_default_tenant_to_users.js create mode 100644 packages/server/src/modules/UsersModule/commands/SendBulkInvites.service.ts create mode 100644 packages/server/src/modules/ee/Workspaces/commands/SetDefaultWorkspace.service.ts create mode 100644 packages/server/src/modules/ee/Workspaces/dtos/SetDefaultWorkspace.dto.ts create mode 100644 packages/webapp/src/components/WorkspaceSwitchingOverlay.tsx create mode 100644 packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/BuildingWorkspaceStep.tsx create mode 100644 packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawer.tsx create mode 100644 packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawerContent.tsx create mode 100644 packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceForm.tsx create mode 100644 packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceStepper.tsx create mode 100644 packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/InviteUsersStep.tsx create mode 100644 packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawer.tsx create mode 100644 packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawerContent.tsx create mode 100644 packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListTable.tsx create mode 100644 packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/index.ts create mode 100644 packages/webapp/src/style/components/WorkspaceSwitchingOverlay.scss create mode 100644 packages/webapp/src/style/containers/Workspaces/OrganizationsListDrawer.scss diff --git a/.env.example b/.env.example index c650538c6..ee54ba34f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6c814f123..6e315fbd6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.github/workflows/generate-openapi.yml b/.github/workflows/generate-openapi.yml index 7b5dc2066..0bc9de152 100644 --- a/.github/workflows/generate-openapi.yml +++ b/.github/workflows/generate-openapi.yml @@ -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: '' diff --git a/packages/server/.env.example b/packages/server/.env.example index 2a48adf5a..dbaf1eba8 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -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 diff --git a/packages/server/src/common/config/throttle.ts b/packages/server/src/common/config/throttle.ts index 3ab6738ff..cba125de2 100644 --- a/packages/server/src/common/config/throttle.ts +++ b/packages/server/src/common/config/throttle.ts @@ -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), }, })); diff --git a/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js b/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js index 0356c86f4..28098be83 100644 --- a/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js +++ b/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js @@ -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 `); }; diff --git a/packages/server/src/database/system/migrations/20260403000001_add_default_tenant_to_users.js b/packages/server/src/database/system/migrations/20260403000001_add_default_tenant_to_users.js new file mode 100644 index 000000000..d1b29404e --- /dev/null +++ b/packages/server/src/database/system/migrations/20260403000001_add_default_tenant_to_users.js @@ -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'); + }); +}; diff --git a/packages/server/src/modules/System/models/SystemUser.ts b/packages/server/src/modules/System/models/SystemUser.ts index 69587b238..945a80687 100644 --- a/packages/server/src/modules/System/models/SystemUser.ts +++ b/packages/server/src/modules/System/models/SystemUser.ts @@ -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; diff --git a/packages/server/src/modules/UsersModule/Users.application.ts b/packages/server/src/modules/UsersModule/Users.application.ts index e0bf73d77..8bcfb775b 100644 --- a/packages/server/src/modules/UsersModule/Users.application.ts +++ b/packages/server/src/modules/UsersModule/Users.application.ts @@ -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); + } } diff --git a/packages/server/src/modules/UsersModule/Users.module.ts b/packages/server/src/modules/UsersModule/Users.module.ts index 3e16edd91..f75d742ac 100644 --- a/packages/server/src/modules/UsersModule/Users.module.ts +++ b/packages/server/src/modules/UsersModule/Users.module.ts @@ -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], }) diff --git a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts index c1c417e7b..4b7cfb2f7 100644 --- a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts +++ b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts @@ -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.', + }; + } } diff --git a/packages/server/src/modules/UsersModule/commands/SendBulkInvites.service.ts b/packages/server/src/modules/UsersModule/commands/SendBulkInvites.service.ts new file mode 100644 index 000000000..a4dedcfd6 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/SendBulkInvites.service.ts @@ -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 { + 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'; + } +} diff --git a/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts b/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts index 302997c5b..7d1730bec 100644 --- a/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts +++ b/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts @@ -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[]; +} diff --git a/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts b/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts index ced57451c..8f1a26729 100644 --- a/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts +++ b/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts @@ -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 { 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 { + const userId = this.cls.get('userId'); + return this.setDefaultWorkspaceService.setDefaultWorkspace( + userId, + dto.organizationId, + ); + } } diff --git a/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts b/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts index 547d5c6a6..ea867bb29 100644 --- a/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts +++ b/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts @@ -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, diff --git a/packages/server/src/modules/ee/Workspaces/commands/SetDefaultWorkspace.service.ts b/packages/server/src/modules/ee/Workspaces/commands/SetDefaultWorkspace.service.ts new file mode 100644 index 000000000..f89dfcaf6 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/commands/SetDefaultWorkspace.service.ts @@ -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 { + // 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 }); + } +} diff --git a/packages/server/src/modules/ee/Workspaces/dtos/SetDefaultWorkspace.dto.ts b/packages/server/src/modules/ee/Workspaces/dtos/SetDefaultWorkspace.dto.ts new file mode 100644 index 000000000..8cbf7c7c7 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/dtos/SetDefaultWorkspace.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SetDefaultWorkspaceDto { + @ApiProperty({ description: 'The organization ID to set as default' }) + organizationId: string; +} diff --git a/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts b/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts index 0d041e1a2..876b991c8 100644 --- a/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts +++ b/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts @@ -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; } diff --git a/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts b/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts index c857a4697..623701dbc 100644 --- a/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts +++ b/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts @@ -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), + ); } } diff --git a/packages/server/src/modules/ee/Workspaces/transformers/WorkspaceTransformer.ts b/packages/server/src/modules/ee/Workspaces/transformers/WorkspaceTransformer.ts index a5e4393b5..548fea7da 100644 --- a/packages/server/src/modules/ee/Workspaces/transformers/WorkspaceTransformer.ts +++ b/packages/server/src/modules/ee/Workspaces/transformers/WorkspaceTransformer.ts @@ -6,11 +6,13 @@ import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto'; * Transforms UserTenant (workspace membership) to WorkspaceDto. */ export class WorkspaceTransformer extends Transformer { + 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 { }; }; + /** + * 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), }; }; diff --git a/packages/webapp/src/components/Dashboard/Dashboard.tsx b/packages/webapp/src/components/Dashboard/Dashboard.tsx index 4fb04cc23..483db0c8c 100644 --- a/packages/webapp/src/components/Dashboard/Dashboard.tsx +++ b/packages/webapp/src/components/Dashboard/Dashboard.tsx @@ -24,10 +24,12 @@ function DashboardPreferences() { return (
- - - - +
+ + + + +
); } @@ -39,10 +41,12 @@ function DashboardAnyPage() { return (
- - - - +
+ + + + +
); } diff --git a/packages/webapp/src/components/Dashboard/DashboardProvider.tsx b/packages/webapp/src/components/Dashboard/DashboardProvider.tsx index c71904c3d..809e0486d 100644 --- a/packages/webapp/src/components/Dashboard/DashboardProvider.tsx +++ b/packages/webapp/src/components/Dashboard/DashboardProvider.tsx @@ -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; diff --git a/packages/webapp/src/components/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index 05d2b3413..b4c1385ff 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -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() { + + ); } diff --git a/packages/webapp/src/components/WorkspaceSwitchingOverlay.tsx b/packages/webapp/src/components/WorkspaceSwitchingOverlay.tsx new file mode 100644 index 000000000..f22817cc7 --- /dev/null +++ b/packages/webapp/src/components/WorkspaceSwitchingOverlay.tsx @@ -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 ( +
+
+
+
+ {initials} +
+
Switching to
+
{workspaceName}
+
+
+ ); +} diff --git a/packages/webapp/src/components/index.tsx b/packages/webapp/src/components/index.tsx index ce576ec06..46dd8f3d7 100644 --- a/packages/webapp/src/components/index.tsx +++ b/packages/webapp/src/components/index.tsx @@ -62,5 +62,6 @@ export * from './EmptyStatus'; export * from './Postbox'; export * from './AppToaster'; export * from './Layout'; +export * from './WorkspaceSwitchingOverlay'; export { MODIFIER, ContextMenu, AvatarCell }; diff --git a/packages/webapp/src/constants/drawers.ts b/packages/webapp/src/constants/drawers.ts index 6c75d8b2b..aebbd4f84 100644 --- a/packages/webapp/src/constants/drawers.ts +++ b/packages/webapp/src/constants/drawers.ts @@ -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', } diff --git a/packages/webapp/src/containers/Dashboard/WorkspacesSidebar/WorkspacesSidebar.tsx b/packages/webapp/src/containers/Dashboard/WorkspacesSidebar/WorkspacesSidebar.tsx index 6a70deece..c04b3eb59 100644 --- a/packages/webapp/src/containers/Dashboard/WorkspacesSidebar/WorkspacesSidebar.tsx +++ b/packages/webapp/src/containers/Dashboard/WorkspacesSidebar/WorkspacesSidebar.tsx @@ -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 ( + + + + ); +} + +/** + * Add workspace button. + */ +function AddWorkspaceButton({ openDrawer }) { + return ( + + + + ); +} + /** * 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 ( -
-
- {isLoading ? ( -
- -
- ) : ( - workspaces?.map((workspace) => ( - - )) - )} + <> +
+
+ {isLoading ? ( +
+ +
+ ) : ( +
+ {workspaces?.map((workspace) => ( + + ))} +
+ )} +
+
+ + +
-
+ {switchingWorkspaceName && ( + + )} + ); } + +export const WorkspacesSidebar = R.compose(withDrawerActions)( + WorkspacesSidebarRoot, +); diff --git a/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/BuildingWorkspaceStep.tsx b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/BuildingWorkspaceStep.tsx new file mode 100644 index 000000000..42c47a4c7 --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/BuildingWorkspaceStep.tsx @@ -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 ( + + + + + + + + + ); + } + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawer.tsx b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawer.tsx new file mode 100644 index 000000000..90a8ca99d --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawer.tsx @@ -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 ( + + + + + + ); +} + +export const CreateWorkspaceDrawer = R.compose(withDrawers())( + CreateWorkspaceDrawerRoot, +); diff --git a/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawerContent.tsx b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawerContent.tsx new file mode 100644 index 000000000..4f6542fcf --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceDrawerContent.tsx @@ -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 ; +} + +export const CreateWorkspaceDrawerContent = R.compose(withDrawerActions)( + CreateWorkspaceDrawerContentRoot, +); diff --git a/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceForm.tsx b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceForm.tsx new file mode 100644 index 000000000..1b3ea4d39 --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceForm.tsx @@ -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 ( + + {(formikProps) => ( + <> + + + + + + +
+ {/* ---------- Organization name ---------- */} + } fastField> + + + + {/* ---------- Location ---------- */} + } fastField={true}> + } + popoverProps={{ minimal: true }} + buttonProps={{ large: true }} + fastField + /> + + + + + {/* ---------- Base currency ---------- */} + } fastField={true}> + } + buttonProps={{ large: true }} + fastField + /> + + + + {/* ---------- Language ---------- */} + + } fastField> + } + popoverProps={{ minimal: true }} + buttonProps={{ large: true }} + fastField + /> + + + + + {/* --------- Fiscal Year ----------- */} + } fastField> + } + popoverProps={{ minimal: true }} + buttonProps={{ large: true }} + fastField + /> + + + {/* ---------- Time zone ---------- */} + }> + } + popoverProps={{ minimal: true }} + buttonProps={{ + alignText: 'left', + fill: true, + large: true, + }} + /> + + + + + +
+
+
+ + + + + + + + + )} +
+ ); +} diff --git a/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceStepper.tsx b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceStepper.tsx new file mode 100644 index 000000000..3f9d9c614 --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/CreateWorkspaceStepper.tsx @@ -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(null); + + const handleWorkspaceCreated = (data: CreatedWorkspace) => { + setCreatedWorkspace(data); + setStepIndex(1); + }; + + const handleBuildingComplete = () => { + setStepIndex(2); + }; + + const handleInviteComplete = () => { + onClose(); + }; + + return ( + + }> + + + + }> + + + + }> + + + + ); +} diff --git a/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/InviteUsersStep.tsx b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/InviteUsersStep.tsx new file mode 100644 index 000000000..6f7884e52 --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/CreateWorkspaceDrawer/InviteUsersStep.tsx @@ -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([ + { id: generateId(), email: '', roleId: defaultRoleId }, + ]); + + const [errors, setErrors] = useState>({}); + + 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 = {}; + 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 ( + <> + + + + + + + + + + + + {invites.map((invite, index) => ( + + + updateInviteRow(invite.id, 'email', e.target.value)} + placeholder="Email address" + intent={errors[invite.id] ? Intent.DANGER : Intent.NONE} + /> + {errors[invite.id] && ( + + {errors[invite.id]} + + )} + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawer.tsx b/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawer.tsx new file mode 100644 index 000000000..04a71961e --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawer.tsx @@ -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 ( + + + + + + ); +} + +export const OrganizationsListDrawer = R.compose(withDrawers())( + OrganizationsListDrawerRoot, +); diff --git a/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawerContent.tsx b/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawerContent.tsx new file mode 100644 index 000000000..b2b88de6c --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListDrawerContent.tsx @@ -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 ( +
+
+
+

+ {intl.get('workspaces.organizations_list_title', { + fallback: 'Organizations', + })} +

+ + {filteredWorkspaces.length} {filteredWorkspaces.length === 1 ? 'organization' : 'organizations'} + +
+ +
+ +
+ + + +
+ +
+ +
+
+ ); +} + +export const OrganizationsListDrawerContent = R.compose(withDrawerActions)( + OrganizationsListDrawerContentRoot, +); diff --git a/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListTable.tsx b/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListTable.tsx new file mode 100644 index 000000000..cefe3ca24 --- /dev/null +++ b/packages/webapp/src/containers/Workspaces/OrganizationsListDrawer/OrganizationsListTable.tsx @@ -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 ( +
+
+ {workspace.isBuildRunning ? ( + + ) : ( + {initials} + )} +
+
+ {name} + {isActive && ( + + {intl.get('workspaces.current_organization', { fallback: 'Current' })} + + )} +
+
+ ); + }, + }, + { + 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 ( +
+ + {formatCurrency(mockBalance)} + +
+ ); + }, + }, + { + 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 ( +
+ + + {formatCurrency(mockIn)} + + + + {formatCurrency(mockOut)} + +
+ ); + }, + }, + { + 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 ( + + handleSetDefault(workspace.organizationId)} + className="organizations-list-table__default-checkbox" + /> + + ); + }, + }, + { + 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 ( +