From 59d69e2f5cf9e73ee61550262149d0616cb46438 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Fri, 22 May 2026 19:49:27 +0530
Subject: [PATCH] fix(sdk): link dev UI to workspace application detail page
(#20849)
sdk handle auth of one workspace per session -- but server could be
configured as multi or single -- hence for multi get subdomain -- and
for single the localhost fallback!
also: link includes applicationId so it opens the app detail page
directly (not the list)
## QA
multi workspace flag on -
multi workspace flag off -
---
.../integration/utils/setup-app-dev-mocks.ts | 2 +
.../twenty-sdk/src/cli/commands/dev/dev.ts | 4 -
.../api/__tests__/api-client.spec.ts | 91 +++++++++++++++++++
.../src/cli/utilities/api/api-client.ts | 70 ++++++++++++++
.../src/cli/utilities/api/api-service.ts | 4 +
.../dev-mode-orchestrator-state.ts | 3 +-
.../steps/check-server-orchestrator-step.ts | 9 ++
.../cli/utilities/dev/ui/dev-ui-constants.ts | 10 +-
8 files changed, 182 insertions(+), 11 deletions(-)
create mode 100644 packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts
diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts
index 7c43214930..f71b541908 100644
--- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts
@@ -2,6 +2,7 @@ import { vi } from 'vitest';
const mockApiService = {
validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }),
+ getWorkspaceFrontendUrl: vi.fn().mockResolvedValue('http://localhost:3000'),
generateApplicationToken: vi.fn().mockResolvedValue({
success: true,
data: {
@@ -37,6 +38,7 @@ const mockApiService = {
vi.mock('@/cli/utilities/api/api-service', () => ({
ApiService: class {
validateAuth = mockApiService.validateAuth;
+ getWorkspaceFrontendUrl = mockApiService.getWorkspaceFrontendUrl;
generateApplicationToken = mockApiService.generateApplicationToken;
refreshToken = mockApiService.refreshToken;
findApplicationRegistrationByUniversalIdentifier =
diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev.ts b/packages/twenty-sdk/src/cli/commands/dev/dev.ts
index 2e95cff296..b808633865 100644
--- a/packages/twenty-sdk/src/cli/commands/dev/dev.ts
+++ b/packages/twenty-sdk/src/cli/commands/dev/dev.ts
@@ -1,4 +1,3 @@
-import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { DevModeOrchestrator } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator';
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
@@ -37,11 +36,8 @@ export class AppDevCommand {
await checkServerVersionCompatibility();
}
- const config = await new ConfigService().getConfig();
-
const orchestratorState = new OrchestratorState({
appPath,
- frontendUrl: config.apiUrl,
});
if (!options.headless) {
diff --git a/packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts b/packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts
new file mode 100644
index 0000000000..f4536c8b28
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/api/__tests__/api-client.spec.ts
@@ -0,0 +1,91 @@
+import { ApiClient } from '@/cli/utilities/api/api-client';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/cli/utilities/config/config-service', () => ({
+ ConfigService: class {
+ getConfig = vi.fn().mockResolvedValue({ apiUrl: 'http://localhost:2020' });
+ setConfig = vi.fn();
+ },
+}));
+
+describe('ApiClient — frontend URL resolution', () => {
+ let client: ApiClient;
+
+ beforeEach(() => {
+ client = new ApiClient({ disableInterceptors: true });
+ });
+
+ describe('getFrontendUrl', () => {
+ it('returns origin of authorization_endpoint', async () => {
+ vi.spyOn(client.client, 'get').mockResolvedValueOnce({
+ data: { authorization_endpoint: 'http://app.localhost:3001/authorize' },
+ });
+
+ expect(await client.getFrontendUrl()).toBe('http://app.localhost:3001');
+ });
+
+ it('returns null when authorization_endpoint is missing', async () => {
+ vi.spyOn(client.client, 'get').mockResolvedValueOnce({ data: {} });
+
+ expect(await client.getFrontendUrl()).toBeNull();
+ });
+
+ it('returns null on network error', async () => {
+ vi.spyOn(client.client, 'get').mockRejectedValueOnce(new Error('boom'));
+
+ expect(await client.getFrontendUrl()).toBeNull();
+ });
+ });
+
+ describe('getWorkspaceFrontendUrl', () => {
+ it('prefers customUrl over subdomainUrl', async () => {
+ vi.spyOn(client.client, 'post').mockResolvedValueOnce({
+ data: {
+ data: {
+ currentWorkspace: {
+ workspaceUrls: {
+ customUrl: 'https://crm.acme.com',
+ subdomainUrl: 'http://apple.localhost:3001',
+ },
+ },
+ },
+ },
+ });
+
+ expect(await client.getWorkspaceFrontendUrl()).toBe(
+ 'https://crm.acme.com',
+ );
+ });
+
+ it('returns subdomainUrl when customUrl is absent', async () => {
+ vi.spyOn(client.client, 'post').mockResolvedValueOnce({
+ data: {
+ data: {
+ currentWorkspace: {
+ workspaceUrls: {
+ subdomainUrl: 'http://apple.localhost:3001',
+ },
+ },
+ },
+ },
+ });
+
+ expect(await client.getWorkspaceFrontendUrl()).toBe(
+ 'http://apple.localhost:3001',
+ );
+ });
+
+ it('falls back to OAuth discovery URL when workspace query returns null', async () => {
+ vi.spyOn(client.client, 'post').mockResolvedValueOnce({
+ data: { data: { currentWorkspace: null } },
+ });
+ vi.spyOn(client.client, 'get').mockResolvedValueOnce({
+ data: { authorization_endpoint: 'http://app.localhost:3001/authorize' },
+ });
+
+ expect(await client.getWorkspaceFrontendUrl()).toBe(
+ 'http://app.localhost:3001',
+ );
+ });
+ });
+});
diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts
index 6a22d64798..0f897ba719 100644
--- a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts
+++ b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts
@@ -1,6 +1,8 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
+import { isNonEmptyString } from '@sniptt/guards';
import axios, { type AxiosInstance } from 'axios';
import chalk from 'chalk';
+import { isDefined } from 'twenty-shared/utils';
export class ApiClient {
readonly client: AxiosInstance;
@@ -70,6 +72,74 @@ export class ApiClient {
);
}
+ async getFrontendUrl(): Promise {
+ try {
+ const response = await this.client.get(
+ '/.well-known/oauth-authorization-server',
+ { headers: { Accept: 'application/json' } },
+ );
+ const authorizationEndpoint = response.data?.authorization_endpoint;
+
+ if (!isNonEmptyString(authorizationEndpoint)) {
+ return null;
+ }
+
+ return new URL(authorizationEndpoint).origin;
+ } catch {
+ return null;
+ }
+ }
+
+ async getWorkspaceFrontendUrl(): Promise {
+ const workspaceFrontendUrl = await this.getCurrentWorkspaceFrontendUrl();
+
+ if (isDefined(workspaceFrontendUrl)) {
+ return workspaceFrontendUrl;
+ }
+
+ return this.getFrontendUrl();
+ }
+
+ private async getCurrentWorkspaceFrontendUrl(): Promise {
+ try {
+ const query = `
+ query CurrentWorkspaceForFrontendUrl {
+ currentWorkspace {
+ workspaceUrls {
+ subdomainUrl
+ customUrl
+ }
+ }
+ }
+ `;
+
+ const response = await this.client.post(
+ '/metadata',
+ { query },
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: '*/*',
+ },
+ },
+ );
+
+ const workspaceUrls =
+ response.data?.data?.currentWorkspace?.workspaceUrls;
+ const workspaceFrontendUrl = isNonEmptyString(workspaceUrls?.customUrl)
+ ? workspaceUrls.customUrl
+ : workspaceUrls?.subdomainUrl;
+
+ if (!isNonEmptyString(workspaceFrontendUrl)) {
+ return null;
+ }
+
+ return new URL(workspaceFrontendUrl).origin;
+ } catch {
+ return null;
+ }
+ }
+
async validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> {
try {
const query = `
diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts
index 6aa6cb8381..fbf403f63d 100644
--- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts
+++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts
@@ -32,6 +32,10 @@ export class ApiService {
return this.apiClient.validateAuth();
}
+ getWorkspaceFrontendUrl(): Promise {
+ return this.apiClient.getWorkspaceFrontendUrl();
+ }
+
refreshToken(): Promise {
return this.apiClient.refreshToken();
}
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
index b721e452d5..90df3db593 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
@@ -123,9 +123,8 @@ export class OrchestratorState {
private eventIdCounter = 0;
onChange?: () => void;
- constructor(options: { appPath: string; frontendUrl?: string }) {
+ constructor(options: { appPath: string }) {
this.appPath = options.appPath;
- this.frontendUrl = options.frontendUrl;
this.previousObjectsFieldsFingerprint = null;
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts
index dd3bb9fe63..17127992a6 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts
@@ -2,6 +2,7 @@ import { type ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
+import { isDefined } from 'twenty-shared/utils';
export type CheckServerOrchestratorStepOutput = {
isReady: boolean;
@@ -88,6 +89,14 @@ export class CheckServerOrchestratorStep {
step.output = { isReady: true, errorLogged: false };
step.status = 'done';
+ if (!isDefined(this.state.frontendUrl)) {
+ const frontendUrl = await this.apiService.getWorkspaceFrontendUrl();
+
+ if (isDefined(frontendUrl)) {
+ this.state.frontendUrl = frontendUrl;
+ }
+ }
+
if (!wasReady) {
this.notify();
}
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
index b718115be3..e0c3b04857 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
@@ -7,6 +7,7 @@ import {
type OrchestratorStateEntityInfo,
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { SyncableEntity } from 'twenty-shared/application';
+import { isDefined } from 'twenty-shared/utils';
export type DevUiStatus =
| 'idle'
@@ -157,14 +158,13 @@ export const groupEntitiesByType = (
};
export const getApplicationUrl = (state: OrchestratorState): string | null => {
- if (
- !state.frontendUrl ||
- !state.steps.resolveApplication.output.universalIdentifier
- ) {
+ const applicationId = state.steps.resolveApplication.output.applicationId;
+
+ if (!isDefined(state.frontendUrl) || !isDefined(applicationId)) {
return null;
}
- return `${state.frontendUrl}/settings/applications`;
+ return `${state.frontendUrl}/settings/applications/${applicationId}`;
};
export const mergeStepStatuses = (