Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/public/icons/github-enterprise.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface AppConfigFormProps {
description?: string;
placeholder: string;
secret?: boolean;
required?: boolean;
}[];
hasEnvDefaults: boolean;
isConnected: boolean;
Expand Down Expand Up @@ -97,8 +98,10 @@ export const AppConfigForm = ({
toast.success("Credentials saved");
await fetchConfig();
onConfigChange?.();
} catch {
toast.error("Failed to save credentials");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to save credentials",
);
} finally {
setSaving(false);
}
Expand Down Expand Up @@ -158,7 +161,15 @@ export const AppConfigForm = ({
setPendingAction(null);
};

const hasInput = fields.some((f) => !!values[f.name]);
const hasAnyInput = fields.some((f) => !!values[f.name]?.trim());
const hasRequiredFields = fields.some((f) => f.required);
const canSave = hasRequiredFields
? fields.every((f) => {
if (!f.required) return true;
if (f.secret && hasCredentials) return true;
return !!values[f.name]?.trim();
})
: hasAnyInput;
const defaultOpen = enabled || !hasEnvDefaults;

if (loading) {
Expand Down Expand Up @@ -221,7 +232,14 @@ export const AppConfigForm = ({

{fields.map((field) => (
<div key={field.name} className="grid gap-1.5">
<Label htmlFor={`config-${field.name}`}>{field.label}</Label>
<Label htmlFor={`config-${field.name}`}>
{field.label}
{field.required && (
<span className="text-muted-foreground ml-1 text-xs">
(required)
</span>
)}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground">
{field.description}
Expand Down Expand Up @@ -254,7 +272,7 @@ export const AppConfigForm = ({
size="sm"
onClick={handleSave}
loading={saving}
disabled={!hasInput}
disabled={!canSave}
>
{saving ? "Saving..." : "Save credentials"}
</Button>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/apps/[provider]/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const GET = async (request: NextRequest, { params }: Params) => {
redirectUri,
scopes,
state,
config: resolved.config,
});

return NextResponse.redirect(authUrl);
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/app/api/apps/[provider]/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
createConnection,
reconnectConnection,
listConnectionsByProvider,
extractLabel,
connectionIdentity,
} from "@/lib/services/connection-service";
import { logger } from "@/lib/logger";

Expand Down Expand Up @@ -51,6 +51,7 @@ export const GET = async (request: NextRequest, { params }: Params) => {
clientId: resolved.clientId,
clientSecret: resolved.clientSecret,
redirectUri,
config: resolved.config,
});

// Determine if we should reconnect an existing connection:
Expand All @@ -59,7 +60,7 @@ export const GET = async (request: NextRequest, { params }: Params) => {
let reconnectId = state.connectionId as string | undefined;

if (!reconnectId) {
const identity = extractLabel(metadata)?.toLowerCase().trim();
const identity = connectionIdentity(metadata);
if (identity) {
const existing = await listConnectionsByProvider(
state.accountId,
Expand All @@ -72,10 +73,10 @@ export const GET = async (request: NextRequest, { params }: Params) => {
Array.isArray(c.metadata)
)
return false;
const existingIdentity = extractLabel(
c.metadata as Record<string, unknown>,
return (
connectionIdentity(c.metadata as Record<string, unknown>) ===
identity
);
return existingIdentity?.toLowerCase().trim() === identity;
});
if (duplicate) reconnectId = duplicate.id;
}
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/lib/apps/github-enterprise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { AppDefinition } from "./types";
import { buildGithubAuthUrl, exchangeGithubCode } from "./github-oauth";

const cfgFromConfig = (config: Record<string, string>) => {
const baseUrl = config.baseUrl;
if (!baseUrl) {
throw new Error(
"GitHub Enterprise connection is missing baseUrl in config",
);
}
return { baseUrl, apiBase: `${baseUrl}/api/v3` };
};

export const githubEnterprise: AppDefinition = {
id: "github-enterprise",
name: "GitHub Enterprise",
icon: "/icons/github-enterprise.svg",
darkIcon: "/icons/github-enterprise.svg",
description:
"Self-hosted GitHub Enterprise Server (GHES). Connect to your organization's own instance.",
connectionMethod: {
type: "oauth",
defaultScopes: [
"repo",
"user",
"gist",
"notifications",
"project",
"codespace",
"workflow",
],
permissions: [
{
scope: "repo",
name: "Repositories",
description: "Code, issues, and pull requests",
access: "write",
},
{
scope: "user",
name: "Profile",
description: "Email, name, and avatar",
access: "read",
},
{
scope: "gist",
name: "Gists",
description: "Create and manage gists",
access: "write",
},
{
scope: "notifications",
name: "Notifications",
description: "View notifications",
access: "read",
},
{
scope: "project",
name: "Projects",
description: "Manage project boards",
access: "write",
},
{
scope: "codespace",
name: "Codespaces",
description: "Create and manage",
access: "write",
},
{
scope: "workflow",
name: "Actions",
description: "Update workflow files",
access: "write",
},
],
buildAuthUrl: (params) =>
buildGithubAuthUrl(cfgFromConfig(params.config), params),
exchangeCode: async (params) => {
const cfg = cfgFromConfig(params.config);
const result = await exchangeGithubCode(cfg, params);
return {
...result,
metadata: { ...(result.metadata ?? {}), baseUrl: cfg.baseUrl },
};
},
},
available: true,
configurable: {
fields: [
{
name: "baseUrl",
label: "Enterprise URL",
description:
"The root URL of your GitHub Enterprise Server (e.g. https://github.example.com).",
placeholder: "https://github.example.com",
required: true,
},
{
name: "clientId",
label: "Client ID",
placeholder: "Iv1.abc123...",
required: true,
},
{
name: "clientSecret",
label: "Client Secret",
placeholder: "secret_...",
secret: true,
required: true,
},
],
envDefaults: {
baseUrl: "GITHUB_ENTERPRISE_BASE_URL",
clientId: "GITHUB_ENTERPRISE_CLIENT_ID",
clientSecret: "GITHUB_ENTERPRISE_CLIENT_SECRET",
},
},
};
88 changes: 88 additions & 0 deletions apps/web/src/lib/apps/github-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type {
OAuthBuildAuthUrlParams,
OAuthExchangeCodeParams,
OAuthExchangeResult,
} from "./types";

export interface GithubOAuthConfig {
baseUrl: string;
apiBase: string;
}

export const buildGithubAuthUrl = (
cfg: GithubOAuthConfig,
params: OAuthBuildAuthUrlParams,
): string => {
const url = new URL(`${cfg.baseUrl}/login/oauth/authorize`);
url.searchParams.set("client_id", params.clientId);
url.searchParams.set("redirect_uri", params.redirectUri);
url.searchParams.set("scope", params.scopes.join(" "));
url.searchParams.set("state", params.state);
url.searchParams.set("prompt", "select_account");
return url.toString();
};

export const exchangeGithubCode = async (
cfg: GithubOAuthConfig,
params: OAuthExchangeCodeParams,
): Promise<OAuthExchangeResult> => {
const tokenRes = await fetch(`${cfg.baseUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: params.clientId,
client_secret: params.clientSecret,
code: params.code,
redirect_uri: params.redirectUri,
}),
});

if (!tokenRes.ok) {
throw new Error(
`GitHub token exchange failed: ${tokenRes.status} ${tokenRes.statusText}`,
);
}

const tokenData = (await tokenRes.json()) as {
access_token?: string;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
};

if (tokenData.error || !tokenData.access_token) {
throw new Error(
tokenData.error_description ?? "Failed to exchange code for token",
);
}

const credentials: Record<string, unknown> = {
access_token: tokenData.access_token,
token_type: tokenData.token_type,
};
const scopes = tokenData.scope?.split(",").filter(Boolean) ?? [];

let metadata: Record<string, unknown> | undefined;
const userRes = await fetch(`${cfg.apiBase}/user`, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});

if (userRes.ok) {
const user = (await userRes.json()) as {
login?: string;
name?: string;
avatar_url?: string;
};
metadata = {
username: user.login,
name: user.name,
avatarUrl: user.avatar_url,
};
}

return { credentials, scopes, metadata };
};
Loading