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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user