wip
This commit is contained in:
+5
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
+11
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -62,5 +62,6 @@ export * from './EmptyStatus';
|
||||
export * from './Postbox';
|
||||
export * from './AppToaster';
|
||||
export * from './Layout';
|
||||
export * from './WorkspaceSwitchingOverlay';
|
||||
|
||||
export { MODIFIER, ContextMenu, AvatarCell };
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
+100
@@ -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>
|
||||
);
|
||||
}
|
||||
+35
@@ -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,
|
||||
);
|
||||
+21
@@ -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,
|
||||
);
|
||||
+206
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+35
@@ -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,
|
||||
);
|
||||
+105
@@ -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,
|
||||
);
|
||||
+252
@@ -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';
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user