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
@@ -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();
}