Skip to content

Commit 7f7ba68

Browse files
authored
feat: add oauth support (#82)
1 parent f9f7900 commit 7f7ba68

File tree

21 files changed

+1114
-296
lines changed

21 files changed

+1114
-296
lines changed

src/api/fetcher.ts

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,72 @@ import type { createApiClient } from "./generated";
22

33
export const buildApiFetcher: (config: {
44
apiToken: string;
5+
onTokenRefresh?: () => Promise<string>;
56
}) => Parameters<typeof createApiClient>[0] = (config) => {
6-
return {
7-
fetch: async (input) => {
8-
const headers = new Headers();
9-
headers.set("Authorization", `Bearer ${config.apiToken}`);
7+
const makeRequest = async (
8+
input: Parameters<Parameters<typeof createApiClient>[0]["fetch"]>[0],
9+
token: string,
10+
): Promise<Response> => {
11+
const headers = new Headers();
12+
headers.set("Authorization", `Bearer ${token}`);
1013

11-
if (input.urlSearchParams) {
12-
input.url.search = input.urlSearchParams.toString();
13-
}
14+
if (input.urlSearchParams) {
15+
input.url.search = input.urlSearchParams.toString();
16+
}
1417

15-
const body = ["post", "put", "patch", "delete"].includes(
16-
input.method.toLowerCase(),
17-
)
18-
? JSON.stringify(input.parameters?.body)
19-
: undefined;
18+
const body = ["post", "put", "patch", "delete"].includes(
19+
input.method.toLowerCase(),
20+
)
21+
? JSON.stringify(input.parameters?.body)
22+
: undefined;
2023

21-
if (body) {
22-
headers.set("Content-Type", "application/json");
23-
}
24+
if (body) {
25+
headers.set("Content-Type", "application/json");
26+
}
2427

25-
if (input.parameters?.header) {
26-
for (const [key, value] of Object.entries(input.parameters.header)) {
27-
if (value != null) {
28-
headers.set(key, String(value));
29-
}
28+
if (input.parameters?.header) {
29+
for (const [key, value] of Object.entries(input.parameters.header)) {
30+
if (value != null) {
31+
headers.set(key, String(value));
3032
}
3133
}
34+
}
3235

33-
let response: Response;
34-
try {
35-
response = await fetch(input.url, {
36-
method: input.method.toUpperCase(),
37-
...(body && { body }),
38-
headers,
39-
...input.overrides,
40-
});
41-
} catch (err) {
42-
throw new Error(
43-
`Network request failed for ${input.method.toUpperCase()} ${input.url}: ${
44-
err instanceof Error ? err.message : String(err)
45-
}`,
46-
{ cause: err instanceof Error ? err : undefined },
47-
);
36+
try {
37+
const response = await fetch(input.url, {
38+
method: input.method.toUpperCase(),
39+
...(body && { body }),
40+
headers,
41+
...input.overrides,
42+
});
43+
44+
return response;
45+
} catch (err) {
46+
throw new Error(
47+
`Network request failed for ${input.method.toUpperCase()} ${input.url}: ${
48+
err instanceof Error ? err.message : String(err)
49+
}`,
50+
{ cause: err instanceof Error ? err : undefined },
51+
);
52+
}
53+
};
54+
55+
return {
56+
fetch: async (input) => {
57+
let response = await makeRequest(input, config.apiToken);
58+
59+
// Handle 401 with automatic token refresh
60+
if (!response.ok && response.status === 401 && config.onTokenRefresh) {
61+
try {
62+
const newToken = await config.onTokenRefresh();
63+
response = await makeRequest(input, newToken);
64+
} catch {
65+
// Token refresh failed - throw the original 401 error
66+
const errorResponse = await response.json();
67+
throw new Error(
68+
`Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`,
69+
);
70+
}
4871
}
4972

5073
if (!response.ok) {

src/api/posthogClient.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,23 @@ export class PostHogAPIClient {
66
private api: ReturnType<typeof createApiClient>;
77
private _teamId: number | null = null;
88

9-
constructor(apiKey: string, apiHost: string) {
9+
constructor(
10+
accessToken: string,
11+
apiHost: string,
12+
onTokenRefresh?: () => Promise<string>,
13+
teamId?: number,
14+
) {
1015
const baseUrl = apiHost.endsWith("/") ? apiHost.slice(0, -1) : apiHost;
11-
this.api = createApiClient(buildApiFetcher({ apiToken: apiKey }), baseUrl);
16+
this.api = createApiClient(
17+
buildApiFetcher({
18+
apiToken: accessToken,
19+
onTokenRefresh,
20+
}),
21+
baseUrl,
22+
);
23+
if (teamId) {
24+
this._teamId = teamId;
25+
}
1226
}
1327

1428
private async getTeamId(): Promise<number> {

src/constants/environment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const IS_DEV = import.meta.env.DEV as boolean;

src/constants/oauth.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { CloudRegion } from "../shared/types/oauth";
2+
3+
export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W";
4+
export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9";
5+
export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ";
6+
7+
export const OAUTH_PORT = 8237;
8+
9+
export const OAUTH_SCOPES = [
10+
"user:read",
11+
"project:read",
12+
"task:write",
13+
"integration:read",
14+
];
15+
16+
// Token refresh settings
17+
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry
18+
19+
export function getCloudUrlFromRegion(region: CloudRegion): string {
20+
switch (region) {
21+
case "us":
22+
return "https://us.posthog.com";
23+
case "eu":
24+
return "https://eu.posthog.com";
25+
case "dev":
26+
return "http://localhost:8010";
27+
}
28+
}
29+
30+
export function getOauthClientIdFromRegion(region: CloudRegion): string {
31+
switch (region) {
32+
case "us":
33+
return POSTHOG_US_CLIENT_ID;
34+
case "eu":
35+
return POSTHOG_EU_CLIENT_ID;
36+
case "dev":
37+
return POSTHOG_DEV_CLIENT_ID;
38+
}
39+
}

src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "electron";
1313
import { registerAgentIpc, type TaskController } from "./services/agent.js";
1414
import { registerFsIpc } from "./services/fs.js";
15+
import { registerOAuthHandlers } from "./services/oauth.js";
1516
import { registerOsIpc } from "./services/os.js";
1617
import { registerPosthogIpc } from "./services/posthog.js";
1718
import {
@@ -192,6 +193,7 @@ ipcMain.handle("app:get-version", () => app.getVersion());
192193

193194
// Register IPC handlers via services
194195
registerPosthogIpc();
196+
registerOAuthHandlers();
195197
registerOsIpc(() => mainWindow);
196198
registerAgentIpc(taskControllers, () => mainWindow);
197199
registerFsIpc();

src/main/preload.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron";
22
import type { Recording } from "../shared/types";
3+
import type {
4+
CloudRegion,
5+
OAuthTokenResponse,
6+
StoredOAuthTokens,
7+
} from "../shared/types/oauth";
38

49
interface MessageBoxOptions {
510
type?: "info" | "error" | "warning" | "question";
@@ -29,6 +34,26 @@ contextBridge.exposeInMainWorld("electronAPI", {
2934
ipcRenderer.invoke("store-api-key", apiKey),
3035
retrieveApiKey: (encryptedKey: string): Promise<string | null> =>
3136
ipcRenderer.invoke("retrieve-api-key", encryptedKey),
37+
// OAuth API
38+
oauthStartFlow: (
39+
region: CloudRegion,
40+
): Promise<{ success: boolean; data?: OAuthTokenResponse; error?: string }> =>
41+
ipcRenderer.invoke("oauth:start-flow", region),
42+
oauthEncryptTokens: (
43+
tokens: StoredOAuthTokens,
44+
): Promise<{ success: boolean; encrypted?: string; error?: string }> =>
45+
ipcRenderer.invoke("oauth:encrypt-tokens", tokens),
46+
oauthRetrieveTokens: (
47+
encrypted: string,
48+
): Promise<{ success: boolean; data?: StoredOAuthTokens; error?: string }> =>
49+
ipcRenderer.invoke("oauth:retrieve-tokens", encrypted),
50+
oauthDeleteTokens: (): Promise<{ success: boolean }> =>
51+
ipcRenderer.invoke("oauth:delete-tokens"),
52+
oauthRefreshToken: (
53+
refreshToken: string,
54+
region: CloudRegion,
55+
): Promise<{ success: boolean; data?: OAuthTokenResponse; error?: string }> =>
56+
ipcRenderer.invoke("oauth:refresh-token", refreshToken, region),
3257
selectDirectory: (): Promise<string | null> =>
3358
ipcRenderer.invoke("select-directory"),
3459
searchDirectories: (query: string, searchRoot?: string): Promise<string[]> =>

0 commit comments

Comments
 (0)