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 -

<img width="2996" height="1712" alt="CleanShot 2026-05-22 at 18 21
31@2x"
src="https://github.com/user-attachments/assets/8499b9f3-b22e-45e2-8b97-4b27fadc3c94"
/>

multi workspace flag off - 

<img width="3012" height="1734" alt="CleanShot 2026-05-22 at 18 14
37@2x"
src="https://github.com/user-attachments/assets/3af2f492-5e2d-4a4b-8251-c3343d79ae9e"
/>
This commit is contained in:
nitin
2026-05-22 19:49:27 +05:30
committed by GitHub
parent 4554dbe3c9
commit 59d69e2f5c
8 changed files with 182 additions and 11 deletions
@@ -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 =
@@ -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) {
@@ -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',
);
});
});
});
@@ -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<string | null> {
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<string | null> {
const workspaceFrontendUrl = await this.getCurrentWorkspaceFrontendUrl();
if (isDefined(workspaceFrontendUrl)) {
return workspaceFrontendUrl;
}
return this.getFrontendUrl();
}
private async getCurrentWorkspaceFrontendUrl(): Promise<string | null> {
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 = `
@@ -32,6 +32,10 @@ export class ApiService {
return this.apiClient.validateAuth();
}
getWorkspaceFrontendUrl(): Promise<string | null> {
return this.apiClient.getWorkspaceFrontendUrl();
}
refreshToken(): Promise<string | null> {
return this.apiClient.refreshToken();
}
@@ -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;
@@ -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();
}
@@ -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 = (