Skip to content

Commit d32b3cc

Browse files
authored
better ux for connections (#158)
* better connections dx * better ux * improvements * fixes * consistent dialogs * fixes * auto-select * fixes * fixes loop * fixes * fixes
1 parent 21b6142 commit d32b3cc

18 files changed

+1396
-631
lines changed

app/api/integrations/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type GetIntegrationsResponse = {
1616
}[];
1717

1818
export type CreateIntegrationRequest = {
19-
name: string;
19+
name?: string;
2020
type: IntegrationType;
2121
config: IntegrationConfig;
2222
};
@@ -92,16 +92,16 @@ export async function POST(request: Request) {
9292

9393
const body: CreateIntegrationRequest = await request.json();
9494

95-
if (!(body.name && body.type && body.config)) {
95+
if (!(body.type && body.config)) {
9696
return NextResponse.json(
97-
{ error: "Name, type, and config are required" },
97+
{ error: "Type and config are required" },
9898
{ status: 400 }
9999
);
100100
}
101101

102102
const integration = await createIntegration(
103103
session.user.id,
104-
body.name,
104+
body.name || "",
105105
body.type,
106106
body.config
107107
);

app/api/integrations/test/route.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { NextResponse } from "next/server";
2+
import postgres from "postgres";
3+
import { auth } from "@/lib/auth";
4+
import type {
5+
IntegrationConfig,
6+
IntegrationType,
7+
} from "@/lib/types/integration";
8+
import {
9+
getCredentialMapping,
10+
getIntegration as getPluginFromRegistry,
11+
} from "@/plugins";
12+
13+
export type TestConnectionRequest = {
14+
type: IntegrationType;
15+
config: IntegrationConfig;
16+
};
17+
18+
export type TestConnectionResult = {
19+
status: "success" | "error";
20+
message: string;
21+
};
22+
23+
/**
24+
* POST /api/integrations/test
25+
* Test connection credentials without saving
26+
*/
27+
export async function POST(request: Request) {
28+
try {
29+
const session = await auth.api.getSession({
30+
headers: request.headers,
31+
});
32+
33+
if (!session?.user) {
34+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
35+
}
36+
37+
const body: TestConnectionRequest = await request.json();
38+
39+
if (!(body.type && body.config)) {
40+
return NextResponse.json(
41+
{ error: "Type and config are required" },
42+
{ status: 400 }
43+
);
44+
}
45+
46+
if (body.type === "database") {
47+
const result = await testDatabaseConnection(body.config.url);
48+
return NextResponse.json(result);
49+
}
50+
51+
const plugin = getPluginFromRegistry(body.type);
52+
53+
if (!plugin) {
54+
return NextResponse.json(
55+
{ error: "Invalid integration type" },
56+
{ status: 400 }
57+
);
58+
}
59+
60+
if (!plugin.testConfig) {
61+
return NextResponse.json(
62+
{ error: "Integration does not support testing" },
63+
{ status: 400 }
64+
);
65+
}
66+
67+
const credentials = getCredentialMapping(plugin, body.config);
68+
69+
const testFn = await plugin.testConfig.getTestFunction();
70+
const testResult = await testFn(credentials);
71+
72+
const result: TestConnectionResult = {
73+
status: testResult.success ? "success" : "error",
74+
message: testResult.success
75+
? "Connection successful"
76+
: testResult.error || "Connection failed",
77+
};
78+
79+
return NextResponse.json(result);
80+
} catch (error) {
81+
console.error("Failed to test connection:", error);
82+
return NextResponse.json(
83+
{
84+
status: "error",
85+
message:
86+
error instanceof Error ? error.message : "Failed to test connection",
87+
},
88+
{ status: 500 }
89+
);
90+
}
91+
}
92+
93+
async function testDatabaseConnection(
94+
databaseUrl?: string
95+
): Promise<TestConnectionResult> {
96+
let connection: postgres.Sql | null = null;
97+
98+
try {
99+
if (!databaseUrl) {
100+
return {
101+
status: "error",
102+
message: "Connection failed",
103+
};
104+
}
105+
106+
connection = postgres(databaseUrl, {
107+
max: 1,
108+
idle_timeout: 5,
109+
connect_timeout: 5,
110+
});
111+
112+
await connection`SELECT 1`;
113+
114+
return {
115+
status: "success",
116+
message: "Connection successful",
117+
};
118+
} catch {
119+
return {
120+
status: "error",
121+
message: "Connection failed",
122+
};
123+
} finally {
124+
if (connection) {
125+
await connection.end();
126+
}
127+
}
128+
}

components/settings/api-keys-dialog.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -253,25 +253,38 @@ export function ApiKeysDialog({ open, onOpenChange }: ApiKeysDialogProps) {
253253
Create a new API key for webhook authentication
254254
</DialogDescription>
255255
</DialogHeader>
256-
<div className="space-y-4 py-4">
257-
<div className="space-y-2">
258-
<Label htmlFor="key-name">Label (optional)</Label>
259-
<Input
260-
id="key-name"
261-
onChange={(e) => setNewKeyName(e.target.value)}
262-
placeholder="e.g., Production, Testing"
263-
value={newKeyName}
264-
/>
256+
<form
257+
id="create-api-key-form"
258+
onSubmit={(e) => {
259+
e.preventDefault();
260+
handleCreate();
261+
}}
262+
>
263+
<div className="space-y-4 py-4">
264+
<div className="space-y-2">
265+
<Label htmlFor="key-name">Label (optional)</Label>
266+
<Input
267+
id="key-name"
268+
onChange={(e) => setNewKeyName(e.target.value)}
269+
placeholder="e.g., Production, Testing"
270+
value={newKeyName}
271+
/>
272+
</div>
265273
</div>
266-
</div>
274+
</form>
267275
<DialogFooter>
268276
<Button
269277
onClick={() => setShowCreateDialog(false)}
278+
type="button"
270279
variant="outline"
271280
>
272281
Cancel
273282
</Button>
274-
<Button disabled={creating} onClick={handleCreate}>
283+
<Button
284+
disabled={creating}
285+
form="create-api-key-form"
286+
type="submit"
287+
>
275288
{creating ? <Spinner className="mr-2 size-4" /> : null}
276289
Create
277290
</Button>

components/settings/index.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,36 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
8383
<Spinner />
8484
</div>
8585
) : (
86-
<div className="mt-4">
86+
<form
87+
className="mt-4"
88+
id="settings-form"
89+
onSubmit={(e) => {
90+
e.preventDefault();
91+
saveAccount();
92+
}}
93+
>
8794
<AccountSettings
8895
accountEmail={accountEmail}
8996
accountName={accountName}
9097
onEmailChange={setAccountEmail}
9198
onNameChange={setAccountName}
9299
/>
93-
</div>
100+
</form>
94101
)}
95102

96103
<DialogFooter>
97-
<Button onClick={() => onOpenChange(false)} variant="outline">
104+
<Button
105+
onClick={() => onOpenChange(false)}
106+
type="button"
107+
variant="outline"
108+
>
98109
Cancel
99110
</Button>
100-
<Button disabled={loading || saving} onClick={saveAccount}>
111+
<Button
112+
disabled={loading || saving}
113+
form="settings-form"
114+
type="submit"
115+
>
101116
{saving ? <Spinner className="mr-2 size-4" /> : null}
102117
Save
103118
</Button>

0 commit comments

Comments
 (0)