ThingsBoard Connector Plugin - Lifecycle, Discovery & Frontend (Plan 2 of 3) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: On top of the Plan 1 foundation, let each organization configure a ThingsBoard connection, run a 5-state connect lifecycle that discovers and maps/creates devices, and manage it from an Integration Settings page.

Architecture: A new IntegrationsController (/api/v1/integrations) backed by IntegrationConnectionService (config CRUD + lifecycle state machine) and DeviceImportService (discovery → link/create). Both use the Plan 1 ConnectorConnectionManager / ConnectorRegistry / CredentialCipher and the integration_connections / device_integrations tables. The frontend adds a permission-gated /settings/integrations page following existing devices-feature conventions.

Tech Stack: NestJS 11, TypeORM 0.3, Jest (backend); React 19 + Ant Design + TanStack Query + Zod + Vitest (frontend).

Prerequisite: Plan 1 (docs/superpowers/plans/2026-07-11-thingsboard-connector-foundation.md) is merged. This plan imports ConnectorConnectionManager, ConnectorRegistry, CredentialCipher, IntegrationConnection, DeviceIntegration, IntegrationStatus, and the DeviceConnector interface from @/integrations/....

Design spec: docs/superpowers/specs/2026-07-11-thingsboard-connector-plugin-design.md Sections 5, 6, 9, 10 (RBAC part of 9).

Key design decision (import path): DevicesService.create() provisions a new TB device and auto-generates a serial. The import path must NOT do that - imported devices adopt the remote TB device’s name as their serialNumber and link the existing remote externalId. DeviceImportService therefore inserts Device rows directly (serial = remote name), it does not call DevicesService.create().


File Structure

Backend - create:

  • backend/src/integrations/dto/configure-connection.dto.ts
  • backend/src/integrations/dto/connection-response.dto.ts
  • backend/src/integrations/services/integration-connection.service.ts (+ .spec.ts)
  • backend/src/integrations/services/device-import.service.ts (+ .spec.ts)
  • backend/src/integrations/integrations.controller.ts
  • backend/test/integrations/integration-connect.e2e-spec.ts
  • backend/src/migrations/1778930000000-AddIntegrationPermissionsToRoles.ts

Backend - modify:

  • backend/src/common/enums/resource.enum.ts - add INTEGRATION.
  • backend/src/common/enums/action.enum.ts - add MANAGE.
  • backend/src/rbac/resource-scope-map.ts - add integration entry.
  • backend/src/integrations/integrations.module.ts (from Plan 1) - add the two services + controller, inject Device repo.
  • backend/src/app.module.ts - Device is already an entity; no change needed beyond Plan 1.

Frontend - create:

  • frontend/src/types/integration.types.ts
  • frontend/src/schemas/integration.schema.ts
  • frontend/src/services/integration.service.ts
  • frontend/src/hooks/useIntegrations.ts (+ useIntegrations.spec.ts optional)
  • frontend/src/pages/settings/IntegrationSettingsPage.tsx (+ .spec.tsx)

Frontend - modify:

  • frontend/src/App.tsx - route.
  • frontend/src/navigation/menus/admin.menu.tsx and owner.menu.tsx - nav item.
  • frontend/src/schemas/index.ts - re-export.

Task 1: Add integration to the RBAC vocabulary

Files:

  • Modify: backend/src/common/enums/resource.enum.ts

  • Modify: backend/src/common/enums/action.enum.ts

  • Modify: backend/src/rbac/resource-scope-map.ts

  • Step 1: Add the resource enum value

In backend/src/common/enums/resource.enum.ts, add to the Resource enum (matching the existing string-value style):

  INTEGRATION = 'integration',
  • Step 2: Add the manage action

In backend/src/common/enums/action.enum.ts, add to the Action enum:

  MANAGE = 'manage',
  • Step 3: Register the resource scope config

In backend/src/rbac/resource-scope-map.ts, add an entry inside RESOURCE_SCOPE_MAP (an integration connection is org-scoped, not assigned per pond/area):

  integration: {
    tenantResource: true,
    assignmentMode: 'none',
  },
  • Step 4: Verify build

Run: cd backend && bun run build
Expected: compiles cleanly.

  • Step 5: Commit
git add backend/src/common/enums/resource.enum.ts backend/src/common/enums/action.enum.ts backend/src/rbac/resource-scope-map.ts
git commit -m "feat(rbac): add integration resource and manage action"

Task 2: Grant integration permissions to roles (migration)

Model on backend/src/migrations/1741500000000-AddInventoryPermissionsToRoles.ts (additive JSONB merge). Org admin (...002) gets full control; manager (...003) gets read.

Files:

  • Create: backend/src/migrations/1778930000000-AddIntegrationPermissionsToRoles.ts

  • Step 1: Write the migration

// backend/src/migrations/1778930000000-AddIntegrationPermissionsToRoles.ts
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddIntegrationPermissionsToRoles1778930000000 implements MigrationInterface {
  name = 'AddIntegrationPermissionsToRoles1778930000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      WITH additions(role_id, permission) AS (
        VALUES
          ('00000000-0000-0000-0000-000000000002'::uuid, '{"resource":"integration","action":"*","scope":"all"}'::jsonb),
          ('00000000-0000-0000-0000-000000000003'::uuid, '{"resource":"integration","action":"read","scope":"all"}'::jsonb)
      )
      UPDATE roles r
      SET permissions = (
        SELECT COALESCE(jsonb_agg(DISTINCT merged.permission), '[]'::jsonb)
        FROM (
          SELECT existing_permission AS permission
          FROM jsonb_array_elements(COALESCE(r.permissions, '[]'::jsonb)) existing_permission
          UNION
          SELECT additions.permission FROM additions WHERE additions.role_id = r.id
        ) merged
      )
      WHERE r.id IN (
        '00000000-0000-0000-0000-000000000002',
        '00000000-0000-0000-0000-000000000003'
      );
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      UPDATE roles r
      SET permissions = (
        SELECT COALESCE(jsonb_agg(p), '[]'::jsonb)
        FROM jsonb_array_elements(COALESCE(r.permissions, '[]'::jsonb)) p
        WHERE p->>'resource' <> 'integration'
      )
      WHERE r.id IN (
        '00000000-0000-0000-0000-000000000002',
        '00000000-0000-0000-0000-000000000003'
      );
    `);
  }
}
  • Step 2: Run the migration

Run: cd backend && bun run migration:run
Expected: executes without error.

  • Step 3: Verify rollback then re-apply

Run: cd backend && bun run migration:revert && bun run migration:run
Expected: revert removes only integration permissions; re-run re-adds them.

  • Step 4: Commit
git add backend/src/migrations/1778930000000-AddIntegrationPermissionsToRoles.ts
git commit -m "feat(rbac): grant integration permissions to admin and manager roles"

Task 3: Connection DTOs

Files:

  • Create: backend/src/integrations/dto/configure-connection.dto.ts

  • Create: backend/src/integrations/dto/connection-response.dto.ts

  • Step 1: Write the request DTO

// backend/src/integrations/dto/configure-connection.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUrl } from 'class-validator';

export class ConfigureConnectionDto {
  @ApiProperty({ description: 'Integration provider', enum: ['thingsboard'] })
  @IsIn(['thingsboard'])
  provider: 'thingsboard';

  @ApiProperty({ description: 'ThingsBoard REST base URL' })
  @IsUrl({ require_tld: false })
  baseUrl: string;

  @ApiProperty({ required: false, description: 'ThingsBoard WebSocket URL' })
  @IsOptional()
  @IsString()
  wsUrl?: string;

  @ApiProperty({ description: 'ThingsBoard tenant username' })
  @IsString()
  @IsNotEmpty()
  username: string;

  @ApiProperty({ description: 'ThingsBoard tenant password' })
  @IsString()
  @IsNotEmpty()
  password: string;
}
  • Step 2: Write the response DTO

Never expose credentials. Expose the ingest token so the operator can paste it into the TB rule chain.

// backend/src/integrations/dto/connection-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IntegrationStatus } from '@/integrations/enums/integration-status.enum';

export class ConnectionResponseDto {
  @ApiProperty()
  id: string;

  @ApiProperty()
  provider: string;

  @ApiProperty()
  baseUrl: string;

  @ApiProperty({ nullable: true })
  wsUrl: string | null;

  @ApiProperty({ enum: IntegrationStatus })
  status: IntegrationStatus;

  @ApiProperty({ description: 'Per-org webhook token for the TB rule chain' })
  ingestToken: string;

  @ApiProperty({ nullable: true })
  lastSyncedAt: Date | null;

  @ApiProperty()
  deviceLinkedCount: number;

  @ApiProperty()
  deviceCreatedCount: number;

  @ApiProperty({ nullable: true })
  lastError: string | null;
}
  • Step 3: Commit
git add backend/src/integrations/dto
git commit -m "feat(integrations): add connection request/response DTOs"

Task 4: DeviceImportService - discovery & mapping

Discovers remote devices and, per remote device, links an existing HMP device with the same serialNumber (within the org) or creates a new one. Idempotent: already-linked externals are skipped. Returns counts.

Files:

  • Create: backend/src/integrations/services/device-import.service.ts

  • Test: backend/src/integrations/services/device-import.service.spec.ts

  • Step 1: Write the failing test

// backend/src/integrations/services/device-import.service.spec.ts
import type { Repository } from 'typeorm';
import type { DeviceConnector } from '@/integrations/connectors/device-connector.interface';
import { CredentialCipher } from '@/integrations/crypto/credential-cipher';
import { DeviceIntegration } from '@/integrations/entities/device-integration.entity';
import { DeviceImportService } from '@/integrations/services/device-import.service';
import { Device } from '@/devices/entities/device.entity';

describe('DeviceImportService', () => {
  const cipher = new CredentialCipher(Buffer.alloc(32, 3).toString('base64'));
  let deviceRepo: { find: jest.Mock; save: jest.Mock };
  let mappingRepo: { find: jest.Mock; save: jest.Mock };
  let connector: jest.Mocked<DeviceConnector>;
  let service: DeviceImportService;

  beforeEach(() => {
    deviceRepo = { find: jest.fn(), save: jest.fn((d) => Promise.resolve({ id: 'new-id', ...d })) };
    mappingRepo = { find: jest.fn().mockResolvedValue([]), save: jest.fn((m) => Promise.resolve(m)) };
    connector = {
      provider: 'thingsboard',
      verifyConnection: jest.fn(),
      discoverDevices: jest.fn(),
      pushDevice: jest.fn(),
      deleteRemoteDevice: jest.fn(),
      getRemoteDeviceCredentials: jest.fn().mockResolvedValue({ accessToken: 'tok' }),
      sendRpc: jest.fn(),
    };
    service = new DeviceImportService(
      deviceRepo as unknown as Repository<Device>,
      mappingRepo as unknown as Repository<DeviceIntegration>,
      cipher,
    );
  });

  it('links an existing device when serialNumber matches the remote name', async () => {
    deviceRepo.find.mockResolvedValue([{ id: 'dev-1', serialNumber: 'SN-001' } as Device]);
    connector.discoverDevices.mockResolvedValue([{ externalId: 'tb-1', name: 'SN-001' }]);

    const result = await service.importDevices('org-1', 'thingsboard', connector);

    expect(result).toEqual({ linked: 1, created: 0 });
    expect(mappingRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ deviceId: 'dev-1', externalId: 'tb-1', provider: 'thingsboard' }),
    );
    expect(deviceRepo.save).not.toHaveBeenCalled();
  });

  it('creates a new device when no serialNumber matches', async () => {
    deviceRepo.find.mockResolvedValue([]);
    connector.discoverDevices.mockResolvedValue([{ externalId: 'tb-2', name: 'SN-999' }]);

    const result = await service.importDevices('org-1', 'thingsboard', connector);

    expect(result).toEqual({ linked: 0, created: 1 });
    expect(deviceRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ serialNumber: 'SN-999', organizationId: 'org-1', type: 'sensor' }),
    );
    expect(mappingRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ deviceId: 'new-id', externalId: 'tb-2' }),
    );
  });

  it('skips remote devices already linked (idempotent resync)', async () => {
    deviceRepo.find.mockResolvedValue([{ id: 'dev-1', serialNumber: 'SN-001' } as Device]);
    mappingRepo.find.mockResolvedValue([{ externalId: 'tb-1' } as DeviceIntegration]);
    connector.discoverDevices.mockResolvedValue([{ externalId: 'tb-1', name: 'SN-001' }]);

    const result = await service.importDevices('org-1', 'thingsboard', connector);

    expect(result).toEqual({ linked: 0, created: 0 });
    expect(mappingRepo.save).not.toHaveBeenCalled();
  });

  it('encrypts the remote access token in the mapping row', async () => {
    deviceRepo.find.mockResolvedValue([{ id: 'dev-1', serialNumber: 'SN-001' } as Device]);
    connector.discoverDevices.mockResolvedValue([{ externalId: 'tb-1', name: 'SN-001' }]);

    await service.importDevices('org-1', 'thingsboard', connector);

    const saved = mappingRepo.save.mock.calls[0][0] as DeviceIntegration;
    expect(saved.credentialsEncrypted).toBeTruthy();
    expect(cipher.decrypt(saved.credentialsEncrypted as string)).toBe('tok');
  });
});
  • Step 2: Run test to verify it fails

Run: make backend-test FILE=backend/src/integrations/services/device-import.service.spec.ts
Expected: FAIL - cannot find module device-import.service.

  • Step 3: Write the implementation
// backend/src/integrations/services/device-import.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Device } from '@/devices/entities/device.entity';
import type { DeviceConnector } from '@/integrations/connectors/device-connector.interface';
import { CredentialCipher } from '@/integrations/crypto/credential-cipher';
import { DeviceIntegration } from '@/integrations/entities/device-integration.entity';

export interface ImportResult {
  linked: number;
  created: number;
}

/**
 * Discovers remote provider devices and reconciles them with HMP devices:
 * links an existing device with a matching serialNumber, or creates a new one.
 * Idempotent - remote devices already mapped are skipped.
 */
@Injectable()
export class DeviceImportService {
  private readonly logger = new Logger(DeviceImportService.name);

  constructor(
    @InjectRepository(Device)
    private readonly deviceRepo: Repository<Device>,
    @InjectRepository(DeviceIntegration)
    private readonly mappingRepo: Repository<DeviceIntegration>,
    private readonly cipher: CredentialCipher,
  ) {}

  async importDevices(
    organizationId: string,
    provider: string,
    connector: DeviceConnector,
  ): Promise<ImportResult> {
    const remoteDevices = await connector.discoverDevices();

    const existingDevices = await this.deviceRepo.find({ where: { organizationId } });
    const devicesBySerial = new Map(existingDevices.map((d) => [d.serialNumber, d]));

    const existingMappings = await this.mappingRepo.find({ where: { organizationId, provider } });
    const mappedExternalIds = new Set(existingMappings.map((m) => m.externalId));

    let linked = 0;
    let created = 0;

    for (const remote of remoteDevices) {
      if (mappedExternalIds.has(remote.externalId)) {
        continue;
      }

      let device = devicesBySerial.get(remote.name) ?? null;
      if (!device) {
        device = await this.createImportedDevice(organizationId, remote.name);
        created += 1;
      } else {
        linked += 1;
      }

      const credentialsEncrypted = await this.encryptRemoteCredentials(connector, remote.externalId);
      await this.mappingRepo.save({
        deviceId: device.id,
        organizationId,
        provider,
        externalId: remote.externalId,
        credentialsEncrypted,
        lastSyncedAt: new Date(),
      } as DeviceIntegration);
    }

    return { linked, created };
  }

  private async createImportedDevice(organizationId: string, serialNumber: string): Promise<Device> {
    const device = new Device();
    device.serialNumber = serialNumber;
    device.name = serialNumber;
    device.type = 'sensor';
    device.organizationId = organizationId;
    device.status = 'offline';
    device.isActive = true;
    return this.deviceRepo.save(device);
  }

  private async encryptRemoteCredentials(
    connector: DeviceConnector,
    externalId: string,
  ): Promise<string | null> {
    try {
      const { accessToken } = await connector.getRemoteDeviceCredentials(externalId);
      return this.cipher.encrypt(accessToken);
    } catch (error) {
      this.logger.warn(
        `Could not fetch credentials for remote device ${externalId}: ${(error as Error).message}`,
      );
      return null;
    }
  }
}
  • Step 4: Run test to verify it passes

Run: make backend-test FILE=backend/src/integrations/services/device-import.service.spec.ts
Expected: PASS (4 tests).

  • Step 5: Commit
git add backend/src/integrations/services/device-import.service.ts backend/src/integrations/services/device-import.service.spec.ts
git commit -m "feat(integrations): add DeviceImportService discovery and mapping"

Task 5: IntegrationConnectionService - config + lifecycle state machine

Handles configure (upsert + encrypt + generate ingest token), status read, the connect/resync lifecycle (connecting → syncing → connected/error), and disconnect.

Files:

  • Create: backend/src/integrations/services/integration-connection.service.ts

  • Test: backend/src/integrations/services/integration-connection.service.spec.ts

  • Step 1: Write the failing test

// backend/src/integrations/services/integration-connection.service.spec.ts
import type { Repository } from 'typeorm';
import { ConnectorConnectionManager } from '@/integrations/connector-connection.manager';
import type { DeviceConnector } from '@/integrations/connectors/device-connector.interface';
import { CredentialCipher } from '@/integrations/crypto/credential-cipher';
import { IntegrationConnection } from '@/integrations/entities/integration-connection.entity';
import { IntegrationStatus } from '@/integrations/enums/integration-status.enum';
import { IntegrationConnectionService } from '@/integrations/services/integration-connection.service';
import type { DeviceImportService } from '@/integrations/services/device-import.service';

describe('IntegrationConnectionService', () => {
  const cipher = new CredentialCipher(Buffer.alloc(32, 5).toString('base64'));
  let repo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };
  let manager: { getConnector: jest.Mock; clearCache: jest.Mock };
  let importer: { importDevices: jest.Mock };
  let connector: jest.Mocked<DeviceConnector>;
  let service: IntegrationConnectionService;

  beforeEach(() => {
    repo = {
      findOne: jest.fn().mockResolvedValue(null),
      save: jest.fn((row) => Promise.resolve({ id: 'conn-1', ...row })),
      create: jest.fn((row) => row),
    };
    connector = {
      provider: 'thingsboard',
      verifyConnection: jest.fn().mockResolvedValue(undefined),
      discoverDevices: jest.fn(),
      pushDevice: jest.fn(),
      deleteRemoteDevice: jest.fn(),
      getRemoteDeviceCredentials: jest.fn(),
      sendRpc: jest.fn(),
    };
    manager = { getConnector: jest.fn().mockResolvedValue(connector), clearCache: jest.fn() };
    importer = { importDevices: jest.fn().mockResolvedValue({ linked: 2, created: 3 }) };
    service = new IntegrationConnectionService(
      repo as unknown as Repository<IntegrationConnection>,
      cipher,
      manager as unknown as ConnectorConnectionManager,
      importer as unknown as DeviceImportService,
    );
  });

  const configureDto = {
    provider: 'thingsboard' as const,
    baseUrl: 'http://tb',
    username: 'u@tb',
    password: 'pw',
  };

  it('configure encrypts credentials, generates an ingest token, and starts disconnected', async () => {
    const conn = await service.configure('org-1', configureDto);
    const saved = repo.save.mock.calls[0][0] as IntegrationConnection;
    expect(saved.credentialsEncrypted).toBeTruthy();
    expect(cipher.decrypt(saved.credentialsEncrypted)).toBe(
      JSON.stringify({ username: 'u@tb', password: 'pw' }),
    );
    expect(saved.ingestToken).toHaveLength(64);
    expect(saved.status).toBe(IntegrationStatus.DISCONNECTED);
    expect(conn.id).toBe('conn-1');
  });

  it('connect transitions to connected and records sync counts', async () => {
    repo.findOne.mockResolvedValue({
      id: 'conn-1',
      organizationId: 'org-1',
      provider: 'thingsboard',
    } as IntegrationConnection);

    const result = await service.connect('org-1', 'thingsboard');

    expect(connector.verifyConnection).toHaveBeenCalled();
    expect(importer.importDevices).toHaveBeenCalledWith('org-1', 'thingsboard', connector);
    const statuses = repo.save.mock.calls.map((c) => (c[0] as IntegrationConnection).status);
    expect(statuses).toEqual([
      IntegrationStatus.CONNECTING,
      IntegrationStatus.SYNCING,
      IntegrationStatus.CONNECTED,
    ]);
    expect(result.status).toBe(IntegrationStatus.CONNECTED);
    expect(result.deviceLinkedCount).toBe(2);
    expect(result.deviceCreatedCount).toBe(3);
  });

  it('connect goes to error with lastError when verifyConnection fails', async () => {
    repo.findOne.mockResolvedValue({
      id: 'conn-1',
      organizationId: 'org-1',
      provider: 'thingsboard',
    } as IntegrationConnection);
    connector.verifyConnection.mockRejectedValue(new Error('bad credentials'));

    const result = await service.connect('org-1', 'thingsboard');

    expect(result.status).toBe(IntegrationStatus.ERROR);
    expect(result.lastError).toContain('bad credentials');
    expect(importer.importDevices).not.toHaveBeenCalled();
  });

  it('disconnect sets status disconnected and clears the connector cache', async () => {
    repo.findOne.mockResolvedValue({
      id: 'conn-1',
      organizationId: 'org-1',
      provider: 'thingsboard',
    } as IntegrationConnection);

    const result = await service.disconnect('org-1', 'thingsboard');

    expect(result.status).toBe(IntegrationStatus.DISCONNECTED);
    expect(manager.clearCache).toHaveBeenCalledWith('org-1', 'thingsboard');
  });
});
  • Step 2: Run test to verify it fails

Run: make backend-test FILE=backend/src/integrations/services/integration-connection.service.spec.ts
Expected: FAIL - cannot find module integration-connection.service.

  • Step 3: Write the implementation
// backend/src/integrations/services/integration-connection.service.ts
import { randomBytes } from 'node:crypto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConnectorConnectionManager } from '@/integrations/connector-connection.manager';
import { CredentialCipher } from '@/integrations/crypto/credential-cipher';
import type { ConfigureConnectionDto } from '@/integrations/dto/configure-connection.dto';
import { IntegrationConnection } from '@/integrations/entities/integration-connection.entity';
import { IntegrationStatus } from '@/integrations/enums/integration-status.enum';
import { DeviceImportService } from '@/integrations/services/device-import.service';

@Injectable()
export class IntegrationConnectionService {
  constructor(
    @InjectRepository(IntegrationConnection)
    private readonly repo: Repository<IntegrationConnection>,
    private readonly cipher: CredentialCipher,
    private readonly connectionManager: ConnectorConnectionManager,
    private readonly deviceImportService: DeviceImportService,
  ) {}

  async configure(
    organizationId: string,
    dto: ConfigureConnectionDto,
  ): Promise<IntegrationConnection> {
    const existing = await this.repo.findOne({
      where: { organizationId, provider: dto.provider },
    });
    const credentialsEncrypted = this.cipher.encrypt(
      JSON.stringify({ username: dto.username, password: dto.password }),
    );

    const row: IntegrationConnection = (existing ??
      this.repo.create({
        organizationId,
        provider: dto.provider,
        ingestToken: randomBytes(32).toString('hex'),
        status: IntegrationStatus.DISCONNECTED,
      })) as IntegrationConnection;

    row.baseUrl = dto.baseUrl;
    row.wsUrl = dto.wsUrl ?? null;
    row.credentialsEncrypted = credentialsEncrypted;
    if (!row.ingestToken) {
      row.ingestToken = randomBytes(32).toString('hex');
    }

    const saved = await this.repo.save(row);
    this.connectionManager.clearCache(organizationId, dto.provider);
    return saved;
  }

  async getForOrg(
    organizationId: string,
    provider = 'thingsboard',
  ): Promise<IntegrationConnection | null> {
    return this.repo.findOne({ where: { organizationId, provider } });
  }

  async connect(organizationId: string, provider = 'thingsboard'): Promise<IntegrationConnection> {
    const connection = await this.require(organizationId, provider);

    connection.status = IntegrationStatus.CONNECTING;
    connection.lastError = null;
    await this.repo.save(connection);

    try {
      this.connectionManager.clearCache(organizationId, provider);
      const connector = await this.connectionManager.getConnector(organizationId, provider);
      await connector.verifyConnection();

      connection.status = IntegrationStatus.SYNCING;
      await this.repo.save(connection);

      const result = await this.deviceImportService.importDevices(
        organizationId,
        provider,
        connector,
      );

      connection.status = IntegrationStatus.CONNECTED;
      connection.deviceLinkedCount = result.linked;
      connection.deviceCreatedCount = result.created;
      connection.lastSyncedAt = new Date();
      return this.repo.save(connection);
    } catch (error) {
      connection.status = IntegrationStatus.ERROR;
      connection.lastError = (error as Error).message;
      return this.repo.save(connection);
    }
  }

  async resync(organizationId: string, provider = 'thingsboard'): Promise<IntegrationConnection> {
    return this.connect(organizationId, provider);
  }

  async disconnect(
    organizationId: string,
    provider = 'thingsboard',
  ): Promise<IntegrationConnection> {
    const connection = await this.require(organizationId, provider);
    connection.status = IntegrationStatus.DISCONNECTED;
    this.connectionManager.clearCache(organizationId, provider);
    return this.repo.save(connection);
  }

  private async require(
    organizationId: string,
    provider: string,
  ): Promise<IntegrationConnection> {
    const connection = await this.repo.findOne({ where: { organizationId, provider } });
    if (!connection) {
      throw new NotFoundException(`No ${provider} integration configured.`);
    }
    return connection;
  }
}
  • Step 4: Run test to verify it passes

Run: make backend-test FILE=backend/src/integrations/services/integration-connection.service.spec.ts
Expected: PASS (5 tests).

  • Step 5: Commit
git add backend/src/integrations/services/integration-connection.service.ts backend/src/integrations/services/integration-connection.service.spec.ts
git commit -m "feat(integrations): add IntegrationConnectionService with connect state machine"

Task 6: IntegrationsController + module wiring

Files:

  • Create: backend/src/integrations/integrations.controller.ts

  • Modify: backend/src/integrations/integrations.module.ts

  • Step 1: Write the controller

Route prefix /api/v1/integrations. Guards JwtAuthGuard, PermissionsGuard. Read gated by integration:read:all; mutations by integration:manage:all. Maps entity → ConnectionResponseDto (no credentials).

// backend/src/integrations/integrations.controller.ts
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { ConfigureConnectionDto } from '@/integrations/dto/configure-connection.dto';
import { ConnectionResponseDto } from '@/integrations/dto/connection-response.dto';
import type { IntegrationConnection } from '@/integrations/entities/integration-connection.entity';
import { IntegrationConnectionService } from '@/integrations/services/integration-connection.service';
import { BadRequestException } from '@nestjs/common';

@ApiTags('Integrations')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('integrations')
export class IntegrationsController {
  constructor(private readonly connectionService: IntegrationConnectionService) {}

  @Get('thingsboard')
  @RequirePermissions('integration:read:all')
  async get(@CurrentUser() user: CurrentUserData): Promise<ConnectionResponseDto | null> {
    const orgId = this.requireOrg(user);
    const connection = await this.connectionService.getForOrg(orgId, 'thingsboard');
    return connection ? this.toResponse(connection) : null;
  }

  @Post('thingsboard')
  @RequirePermissions('integration:manage:all')
  async configure(
    @CurrentUser() user: CurrentUserData,
    @Body() dto: ConfigureConnectionDto,
  ): Promise<ConnectionResponseDto> {
    const orgId = this.requireOrg(user);
    return this.toResponse(await this.connectionService.configure(orgId, dto));
  }

  @Post('thingsboard/connect')
  @HttpCode(HttpStatus.OK)
  @RequirePermissions('integration:manage:all')
  async connect(@CurrentUser() user: CurrentUserData): Promise<ConnectionResponseDto> {
    const orgId = this.requireOrg(user);
    return this.toResponse(await this.connectionService.connect(orgId, 'thingsboard'));
  }

  @Post('thingsboard/resync')
  @HttpCode(HttpStatus.OK)
  @RequirePermissions('integration:manage:all')
  async resync(@CurrentUser() user: CurrentUserData): Promise<ConnectionResponseDto> {
    const orgId = this.requireOrg(user);
    return this.toResponse(await this.connectionService.resync(orgId, 'thingsboard'));
  }

  @Post('thingsboard/disconnect')
  @HttpCode(HttpStatus.OK)
  @RequirePermissions('integration:manage:all')
  async disconnect(@CurrentUser() user: CurrentUserData): Promise<ConnectionResponseDto> {
    const orgId = this.requireOrg(user);
    return this.toResponse(await this.connectionService.disconnect(orgId, 'thingsboard'));
  }

  private requireOrg(user: CurrentUserData): string {
    if (!user.organizationId) {
      throw new BadRequestException('User must be associated with an organization');
    }
    return user.organizationId;
  }

  private toResponse(c: IntegrationConnection): ConnectionResponseDto {
    return {
      id: c.id,
      provider: c.provider,
      baseUrl: c.baseUrl,
      wsUrl: c.wsUrl,
      status: c.status,
      ingestToken: c.ingestToken,
      lastSyncedAt: c.lastSyncedAt,
      deviceLinkedCount: c.deviceLinkedCount,
      deviceCreatedCount: c.deviceCreatedCount,
      lastError: c.lastError,
    };
  }
}
  • Step 2: Wire services + controller into the module

Update backend/src/integrations/integrations.module.ts (created in Plan 1). Add the Device entity to forFeature (the import service needs its repo), the two services, and the controller:

import { Device } from '@/devices/entities/device.entity';
import { DeviceImportService } from '@/integrations/services/device-import.service';
import { IntegrationConnectionService } from '@/integrations/services/integration-connection.service';
import { IntegrationsController } from '@/integrations/integrations.controller';

Change the @Module decorator to:

@Module({
  imports: [TypeOrmModule.forFeature([IntegrationConnection, DeviceIntegration, Device])],
  controllers: [IntegrationsController],
  providers: [
    {
      provide: CredentialCipher,
      useFactory: (config: ConfigService) => {
        const key = config.get<string>('INTEGRATION_ENCRYPTION_KEY');
        if (!key) {
          throw new Error('INTEGRATION_ENCRYPTION_KEY is not configured.');
        }
        return new CredentialCipher(key);
      },
      inject: [ConfigService],
    },
    ConnectorRegistry,
    ConnectorConnectionManager,
    DeviceImportService,
    IntegrationConnectionService,
  ],
  exports: [ConnectorConnectionManager, ConnectorRegistry, CredentialCipher, TypeOrmModule],
})

(Keep the implements OnModuleInit TB-factory registration from Plan 1 unchanged.)

  • Step 3: Verify build + full integrations suite

Run: cd backend && bun run build
Expected: compiles.

Run: make backend-test FILE=backend/src/integrations
Expected: all integrations specs PASS.

  • Step 4: Commit
git add backend/src/integrations/integrations.controller.ts backend/src/integrations/integrations.module.ts
git commit -m "feat(integrations): add IntegrationsController and wire module"

Task 7: Backend e2e - connect flow

Verifies the HTTP surface end-to-end with a mocked connector (no live TB). Follow the existing e2e style under backend/test/.

Files:

  • Create: backend/test/integrations/integration-connect.e2e-spec.ts

  • Step 1: Write the e2e test

Boot a Nest test app with IntegrationsModule, override ConnectorRegistry so thingsboard yields a fake connector, seed a connection row, and drive configure → connect. Mirror the bootstrapping in an existing spec under backend/test/ (e.g. how tickets.service.e2e-spec.ts builds its DataSource/module). Assert:

// backend/test/integrations/integration-connect.e2e-spec.ts
// Boots IntegrationsModule against a test DataSource, overrides the TB connector
// factory with a fake that returns two discoverable devices, and asserts the
// connect flow reaches CONNECTED with linked/created counts.
//
// Key assertions:
//   - POST configure returns status 'disconnected' and a 64-char ingestToken
//   - POST connect returns status 'connected'
//   - after connect, deviceLinkedCount + deviceCreatedCount === number of remote devices
//   - the response never contains a `credentialsEncrypted`, `username`, or `password` field

Implement the test concretely using the same Test.createTestingModule({ imports: [...] }).overrideProvider(ConnectorRegistry).useValue(...) pattern, a real CredentialCipher built from a test key, and the project’s e2e DataSource setup. Register the fake connector via a registry whose create() returns an object implementing DeviceConnector with discoverDevices resolving to [{ externalId: 'tb-a', name: 'SN-A' }, { externalId: 'tb-b', name: 'SN-B' }] and getRemoteDeviceCredentials resolving { accessToken: 'tok' }.

  • Step 2: Run the e2e test

Run: cd backend && bun run test:e2e -- integration-connect
Expected: PASS. Two devices created (no pre-existing HMP devices with those serials), status connected, no credential fields leaked.

  • Step 3: Commit
git add backend/test/integrations/integration-connect.e2e-spec.ts
git commit -m "test(integrations): e2e connect flow reaches connected with sync counts"

Task 8: Frontend types, schema, service

Files:

  • Create: frontend/src/types/integration.types.ts

  • Create: frontend/src/schemas/integration.schema.ts

  • Create: frontend/src/services/integration.service.ts

  • Modify: frontend/src/schemas/index.ts

  • Step 1: Write the types

// frontend/src/types/integration.types.ts
export type IntegrationStatus =
  | 'disconnected'
  | 'connecting'
  | 'syncing'
  | 'connected'
  | 'error';

export interface IntegrationConnection {
  id: string;
  provider: string;
  baseUrl: string;
  wsUrl: string | null;
  status: IntegrationStatus;
  ingestToken: string;
  lastSyncedAt: string | null;
  deviceLinkedCount: number;
  deviceCreatedCount: number;
  lastError: string | null;
}

export interface ConfigureConnectionInput {
  provider: 'thingsboard';
  baseUrl: string;
  wsUrl?: string;
  username: string;
  password: string;
}
  • Step 2: Write the Zod schema
// frontend/src/schemas/integration.schema.ts
import { z } from 'zod/v4';

export const configureConnectionSchema = z.object({
  baseUrl: z.string().url('URL không hợp lệ'),
  wsUrl: z.string().url('URL không hợp lệ').optional().or(z.literal('')),
  username: z.string().min(1, 'Vui lòng nhập username'),
  password: z.string().min(1, 'Vui lòng nhập password'),
});

export type ConfigureConnectionForm = z.infer<typeof configureConnectionSchema>;

Add to frontend/src/schemas/index.ts:

export * from '@/schemas/integration.schema';
  • Step 3: Write the service
// frontend/src/services/integration.service.ts
import { api } from '@/lib/api';
import type { ConfigureConnectionInput, IntegrationConnection } from '@/types/integration.types';

export const integrationService = {
  async get(): Promise<IntegrationConnection | null> {
    const response = await api.get('/integrations/thingsboard');
    return response.data;
  },

  async configure(data: ConfigureConnectionInput): Promise<IntegrationConnection> {
    const response = await api.post('/integrations/thingsboard', data);
    return response.data;
  },

  async connect(): Promise<IntegrationConnection> {
    const response = await api.post('/integrations/thingsboard/connect');
    return response.data;
  },

  async resync(): Promise<IntegrationConnection> {
    const response = await api.post('/integrations/thingsboard/resync');
    return response.data;
  },

  async disconnect(): Promise<IntegrationConnection> {
    const response = await api.post('/integrations/thingsboard/disconnect');
    return response.data;
  },
};
  • Step 4: Commit
git add frontend/src/types/integration.types.ts frontend/src/schemas/integration.schema.ts frontend/src/schemas/index.ts frontend/src/services/integration.service.ts
git commit -m "feat(frontend): add integration types, schema, and service"

Task 9: Frontend TanStack Query hooks

Files:

  • Create: frontend/src/hooks/useIntegrations.ts

  • Step 1: Write the hooks

Follow the toast pattern from useFarms.ts (message + extractApiError). connect/resync/disconnect/configure invalidate the ['integration','thingsboard'] query.

// frontend/src/hooks/useIntegrations.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { extractApiError } from '@/lib/api';
import { integrationService } from '@/services/integration.service';
import type { ConfigureConnectionInput } from '@/types/integration.types';

const QUERY_KEY = ['integration', 'thingsboard'];

export const useThingsBoardIntegration = () =>
  useQuery({
    queryKey: QUERY_KEY,
    queryFn: () => integrationService.get(),
  });

export const useConfigureIntegration = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (input: ConfigureConnectionInput) => integrationService.configure(input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEY });
      message.success('Đã lưu cấu hình kết nối');
    },
    onError: (error: unknown) => message.error(extractApiError(error) || 'Lưu cấu hình thất bại'),
  });
};

export const useConnectIntegration = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: () => integrationService.connect(),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: QUERY_KEY }),
    onError: (error: unknown) => message.error(extractApiError(error) || 'Kết nối thất bại'),
  });
};

export const useResyncIntegration = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: () => integrationService.resync(),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEY });
      message.success('Đã đồng bộ lại thiết bị');
    },
    onError: (error: unknown) => message.error(extractApiError(error) || 'Đồng bộ thất bại'),
  });
};

export const useDisconnectIntegration = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: () => integrationService.disconnect(),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: QUERY_KEY }),
    onError: (error: unknown) => message.error(extractApiError(error) || 'Ngắt kết nối thất bại'),
  });
};
  • Step 2: Commit
git add frontend/src/hooks/useIntegrations.ts
git commit -m "feat(frontend): add integration TanStack Query hooks"

Task 10: Integration Settings page

Connect form (URL, WS URL, username, password) + status card showing the 5-state progress, sync counts, ingest token, and Resync/Disconnect actions. Mirror DeviceProfilesPage.tsx structure (PageHeader + form + mutation isPending).

Files:

  • Create: frontend/src/pages/settings/IntegrationSettingsPage.tsx

  • Test: frontend/src/pages/settings/IntegrationSettingsPage.spec.tsx

  • Step 1: Write the failing test

// frontend/src/pages/settings/IntegrationSettingsPage.spec.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi } from 'vitest';
import { IntegrationSettingsPage } from '@/pages/settings/IntegrationSettingsPage';

const { mockConfigure, mockConnect, mockResync, mockDisconnect } = vi.hoisted(() => ({
  mockConfigure: vi.fn().mockResolvedValue({}),
  mockConnect: vi.fn().mockResolvedValue({}),
  mockResync: vi.fn().mockResolvedValue({}),
  mockDisconnect: vi.fn().mockResolvedValue({}),
}));

let connection: unknown = null;

vi.mock('@/hooks/useIntegrations', () => ({
  useThingsBoardIntegration: () => ({ data: connection, isLoading: false }),
  useConfigureIntegration: () => ({ mutateAsync: mockConfigure, isPending: false }),
  useConnectIntegration: () => ({ mutateAsync: mockConnect, isPending: false }),
  useResyncIntegration: () => ({ mutateAsync: mockResync, isPending: false }),
  useDisconnectIntegration: () => ({ mutateAsync: mockDisconnect, isPending: false }),
}));

function renderPage() {
  render(
    <QueryClientProvider client={new QueryClient()}>
      <MemoryRouter>
        <IntegrationSettingsPage />
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

describe('IntegrationSettingsPage', () => {
  beforeEach(() => {
    connection = null;
    vi.clearAllMocks();
  });

  it('submits the connect form with entered credentials', async () => {
    renderPage();
    fireEvent.change(screen.getByLabelText('ThingsBoard URL'), {
      target: { value: 'http://tb:8090' },
    });
    fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'u@tb' } });
    fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } });
    fireEvent.click(screen.getByRole('button', { name: 'Lưu & Kết nối' }));

    await waitFor(() =>
      expect(mockConfigure).toHaveBeenCalledWith(
        expect.objectContaining({ provider: 'thingsboard', baseUrl: 'http://tb:8090', username: 'u@tb' }),
      ),
    );
    await waitFor(() => expect(mockConnect).toHaveBeenCalled());
  });

  it('shows the connected status and sync counts', () => {
    connection = {
      id: 'c1',
      provider: 'thingsboard',
      baseUrl: 'http://tb',
      wsUrl: null,
      status: 'connected',
      ingestToken: 'abc',
      lastSyncedAt: '2026-07-11T00:00:00Z',
      deviceLinkedCount: 2,
      deviceCreatedCount: 3,
      lastError: null,
    };
    renderPage();
    expect(screen.getByText(/connected/i)).toBeInTheDocument();
    expect(screen.getByText(/2/)).toBeInTheDocument();
    expect(screen.getByText(/3/)).toBeInTheDocument();
  });

  it('shows the error reason when status is error', () => {
    connection = {
      id: 'c1',
      provider: 'thingsboard',
      baseUrl: 'http://tb',
      wsUrl: null,
      status: 'error',
      ingestToken: 'abc',
      lastSyncedAt: null,
      deviceLinkedCount: 0,
      deviceCreatedCount: 0,
      lastError: 'bad credentials',
    };
    renderPage();
    expect(screen.getByText('bad credentials')).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: make frontend-test FILE=frontend/src/pages/settings/IntegrationSettingsPage.spec.tsx
Expected: FAIL - cannot find module IntegrationSettingsPage.

  • Step 3: Write the page
// frontend/src/pages/settings/IntegrationSettingsPage.tsx
import { Alert, Button, Card, Descriptions, Form, Input, Space, Steps, Tag, Typography } from 'antd';
import type React from 'react';
import { useEffect } from 'react';
import { PageHeader } from '@/components/common/PageHeader';
import {
  useConfigureIntegration,
  useConnectIntegration,
  useDisconnectIntegration,
  useResyncIntegration,
  useThingsBoardIntegration,
} from '@/hooks/useIntegrations';
import type { IntegrationStatus } from '@/types/integration.types';

interface ConnectFormValues {
  baseUrl: string;
  wsUrl?: string;
  username: string;
  password: string;
}

const STATUS_STEP: Record<IntegrationStatus, number> = {
  disconnected: 0,
  connecting: 1,
  syncing: 2,
  connected: 3,
  error: 1,
};

const STATUS_COLOR: Record<IntegrationStatus, string> = {
  disconnected: 'default',
  connecting: 'processing',
  syncing: 'processing',
  connected: 'success',
  error: 'error',
};

export const IntegrationSettingsPage: React.FC = () => {
  const { data: connection } = useThingsBoardIntegration();
  const { mutateAsync: configure, isPending: isConfiguring } = useConfigureIntegration();
  const { mutateAsync: connect, isPending: isConnecting } = useConnectIntegration();
  const { mutateAsync: resync, isPending: isResyncing } = useResyncIntegration();
  const { mutateAsync: disconnect, isPending: isDisconnecting } = useDisconnectIntegration();
  const [form] = Form.useForm<ConnectFormValues>();

  useEffect(() => {
    if (connection) {
      form.setFieldsValue({ baseUrl: connection.baseUrl, wsUrl: connection.wsUrl ?? undefined });
    }
  }, [connection, form]);

  const handleFinish = async (values: ConnectFormValues) => {
    await configure({
      provider: 'thingsboard',
      baseUrl: values.baseUrl,
      wsUrl: values.wsUrl || undefined,
      username: values.username,
      password: values.password,
    });
    await connect();
  };

  const status = connection?.status ?? 'disconnected';

  return (
    <div>
      <PageHeader title="Tích hợp ThingsBoard" subtitle="Kết nối và đồng bộ thiết bị từ ThingsBoard" />

      <Space direction="vertical" size="large" className="w-full">
        {connection && (
          <Card title="Trạng thái kết nối">
            <Space direction="vertical" size="middle" className="w-full">
              <Tag color={STATUS_COLOR[status]}>{status}</Tag>
              <Steps
                size="small"
                current={STATUS_STEP[status]}
                status={status === 'error' ? 'error' : undefined}
                items={[
                  { title: 'Chưa kết nối' },
                  { title: 'Đang xác thực' },
                  { title: 'Đang đồng bộ' },
                  { title: 'Đã kết nối' },
                ]}
              />
              {status === 'error' && connection.lastError && (
                <Alert type="error" message={connection.lastError} showIcon />
              )}
              <Descriptions column={1} size="small" bordered>
                <Descriptions.Item label="Thiết bị đã liên kết">
                  {connection.deviceLinkedCount}
                </Descriptions.Item>
                <Descriptions.Item label="Thiết bị tạo mới">
                  {connection.deviceCreatedCount}
                </Descriptions.Item>
                <Descriptions.Item label="Ingest token (dán vào TB rule chain)">
                  <Typography.Text copyable code>
                    {connection.ingestToken}
                  </Typography.Text>
                </Descriptions.Item>
              </Descriptions>
              <Space>
                <Button loading={isResyncing} onClick={() => resync()}>
                  Đồng bộ lại
                </Button>
                <Button danger loading={isDisconnecting} onClick={() => disconnect()}>
                  Ngắt kết nối
                </Button>
              </Space>
            </Space>
          </Card>
        )}

        <Card title="Cấu hình kết nối">
          <Form form={form} layout="vertical" onFinish={handleFinish}>
            <Form.Item
              label="ThingsBoard URL"
              name="baseUrl"
              rules={[{ required: true, message: 'Vui lòng nhập URL' }]}
            >
              <Input placeholder="http://thingsboard:8090" />
            </Form.Item>
            <Form.Item label="WebSocket URL" name="wsUrl">
              <Input placeholder="ws://thingsboard:8090" />
            </Form.Item>
            <Form.Item
              label="Username"
              name="username"
              rules={[{ required: true, message: 'Vui lòng nhập username' }]}
            >
              <Input placeholder="tenant@thingsboard.org" />
            </Form.Item>
            <Form.Item
              label="Password"
              name="password"
              rules={[{ required: true, message: 'Vui lòng nhập password' }]}
            >
              <Input.Password />
            </Form.Item>
            <Form.Item>
              <Button
                type="primary"
                htmlType="submit"
                loading={isConfiguring || isConnecting}
              >
                Lưu & Kết nối
              </Button>
            </Form.Item>
          </Form>
        </Card>
      </Space>
    </div>
  );
};
  • Step 4: Run test to verify it passes

Run: make frontend-test FILE=frontend/src/pages/settings/IntegrationSettingsPage.spec.tsx
Expected: PASS (3 tests). If the PageHeader prop names differ (subtitle vs description), align to the real PageHeader signature in frontend/src/components/common/PageHeader.tsx.

  • Step 5: Commit
git add frontend/src/pages/settings/IntegrationSettingsPage.tsx frontend/src/pages/settings/IntegrationSettingsPage.spec.tsx
git commit -m "feat(frontend): add Integration Settings page"

Task 11: Route + navigation

Files:

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/navigation/menus/admin.menu.tsx

  • Modify: frontend/src/navigation/menus/owner.menu.tsx

  • Step 1: Add the import + route in App.tsx

Near the other page imports (e.g. after the SettingsPage import), add:

import { IntegrationSettingsPage } from '@/pages/settings/IntegrationSettingsPage';

Immediately after the existing <Route path="/settings" element={<SettingsPage />} /> line, add:

                  <Route element={<PermissionRoute resource="integration" action="manage" />}>
                    <Route
                      path="/settings/integrations"
                      element={<IntegrationSettingsPage />}
                    />
                  </Route>
  • Step 2: Add the nav item to admin.menu.tsx

After the existing admin-settings item (the “Cài đặt tổ chức” entry), add:

  {
    key: 'admin-integrations',
    icon: <Icon icon="solar:plug-circle-bold-duotone" width={16} />,
    label: 'Tích hợp',
    path: '/settings/integrations',
    requiredPermission: { resource: 'integration', action: 'manage' },
    group: 'TỔ CHỨC',
  },
  • Step 3: Add the same nav item to owner.menu.tsx

Add an equivalent NavItem (key owner-integrations, same path/requiredPermission) in the settings/organization group of owner.menu.tsx, matching that file’s existing item shape.

  • Step 4: Verify build + typecheck

Run: cd frontend && bun run build
Expected: compiles with no TS errors.

  • Step 5: Commit
git add frontend/src/App.tsx frontend/src/navigation/menus/admin.menu.tsx frontend/src/navigation/menus/owner.menu.tsx
git commit -m "feat(frontend): add integrations route and nav item"

Task 12: Lint + full test sweep

  • Step 1: Biome

Run: bun run check
Expected: no lint/format issues in touched files. Fix any reported.

  • Step 2: Backend integrations tests

Run: make backend-test FILE=backend/src/integrations
Expected: all PASS.

  • Step 3: Frontend integration tests

Run: make frontend-test FILE=frontend/src/pages/settings/IntegrationSettingsPage.spec.tsx
Expected: PASS.

  • Step 4: Commit any lint fixes
git add -A
git commit -m "chore(integrations): lint and formatting for plan 2"

Self-Review Notes (author)

  • Spec coverage: Section 5 (state machine) - Task 5 (connecting→syncing→connected/error, resync, disconnect). Section 6 (discovery & mapping, match-by-serial, link/create, idempotent, summary counts) - Task 4. Section 9 (frontend page, Resync/Disconnect, status progress, RBAC integration) - Tasks 1-2 (RBAC), 8-11 (frontend). Section 10 frontend page - Tasks 10-11.
  • Type consistency: ImportResult defined in Task 4, consumed in Task 5’s importDevices mock and the service. IntegrationStatus (Plan 1) used across Tasks 5, 6, 8, 10. ConfigureConnectionDto (Task 3) used in Tasks 5, 6. Response mapping toResponse (Task 6) matches ConnectionResponseDto (Task 3) field-for-field, and the frontend IntegrationConnection type (Task 8) mirrors it.
  • Import path decision (adopt remote name as serial, insert Device directly, do NOT call DevicesService.create) is encoded in Task 4 Step 3 and called out in the header.
  • Deferred correctly: telemetry read decoupling, per-org ingest webhook, monitoring migration, column drop → Plan 3. This plan leaves Device.thingsboardDeviceId/accessToken untouched and does not change any existing consumer.
  • Watch-outs for the implementer: confirm the real PageHeader prop names; confirm owner.menu.tsx item shape; the connect flow is synchronous (awaited) - if TB has many devices and requests time out, revisit making connect a background job with status polling (noted, not built).
  • Migration timestamp 1778930000000 is after Plan 1’s 1778920000000.