Add an inline "Verbindung testen" button to the Neuer/Bearbeiten provider
dialogs so users can test a connection with the currently entered values
before saving.
Backend:
- New endpoint POST /providers/test-connection that accepts an ad-hoc provider
config (apiType, baseUrl, model, optional apiToken, optional providerId) in
the request body and runs a one-shot test via the existing callProvider
logic. When apiToken is empty and providerId is given, it falls back to the
stored token of that provider (edit case). Returns { ok, message }; the token
is never returned or leaked (existing redactSecrets still applies to errors).
- Defined ProviderTestConnectionInput schema + path in openapi.yaml and ran
codegen for Zod schemas and the React client.
Frontend (artifacts/skillguard/src/pages/admin.tsx):
- Add dialog: "Verbindung testen" button (disabled until Base URL + Token set
or while testing) with loading spinner and an inline green success / red
error result box. Result resets when the dialog closes.
- Edit dialog: same inline test; empty token field falls back to the stored
token via providerId. Result resets on open/close.
- The existing per-card "Verbindung testen" button is unchanged.
Verification: typecheck passes for api-server and skillguard; curl tested the
new endpoint for success-path (fetch error surfaced), empty-token, and invalid
body (400) cases. Token not present in any response.
Deviations: none.
Replit-Task-Id: 4f77293f-468c-496a-ab05-1f10e7bf8137
204 lines
5.9 KiB
TypeScript
204 lines
5.9 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { db } from "@workspace/db";
|
|
import { aiProvidersTable, type AiProvider } from "@workspace/db";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
ListProvidersResponse,
|
|
CreateProviderBody,
|
|
UpdateProviderParams,
|
|
UpdateProviderBody,
|
|
UpdateProviderResponse,
|
|
DeleteProviderParams,
|
|
TestProviderParams,
|
|
TestProviderResponse,
|
|
TestProviderConnectionBody,
|
|
} from "@workspace/api-zod";
|
|
import { callProvider } from "../lib/aiAnalysis";
|
|
|
|
const router: IRouter = Router();
|
|
|
|
function maskToken(token: string | null): string {
|
|
if (!token) return "";
|
|
if (token.length <= 8) return "••••";
|
|
return `${token.slice(0, 3)}…${token.slice(-4)}`;
|
|
}
|
|
|
|
function serializeProvider(p: AiProvider) {
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
apiType: p.apiType,
|
|
baseUrl: p.baseUrl,
|
|
model: p.model,
|
|
enabled: p.enabled,
|
|
hasToken: !!p.apiToken,
|
|
tokenPreview: maskToken(p.apiToken),
|
|
createdAt: p.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
router.get("/providers", async (_req, res) => {
|
|
const rows = await db.select().from(aiProvidersTable).orderBy(aiProvidersTable.id);
|
|
res.json(ListProvidersResponse.parse(rows.map(serializeProvider)));
|
|
});
|
|
|
|
router.post("/providers", async (req, res) => {
|
|
const parsed = CreateProviderBody.safeParse(req.body);
|
|
if (!parsed.success)
|
|
return res
|
|
.status(400)
|
|
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
|
const d = parsed.data;
|
|
const [created] = await db
|
|
.insert(aiProvidersTable)
|
|
.values({
|
|
name: d.name,
|
|
apiType: d.apiType,
|
|
baseUrl: d.baseUrl,
|
|
model: d.model,
|
|
apiToken: d.apiToken ?? null,
|
|
enabled: d.enabled ?? true,
|
|
})
|
|
.returning();
|
|
return res
|
|
.status(201)
|
|
.json(UpdateProviderResponse.parse(serializeProvider(created)));
|
|
});
|
|
|
|
router.patch("/providers/:id", async (req, res) => {
|
|
const params = UpdateProviderParams.safeParse(req.params);
|
|
if (!params.success) return res.status(400).json({ message: "Ungültige ID" });
|
|
const parsed = UpdateProviderBody.safeParse(req.body);
|
|
if (!parsed.success)
|
|
return res
|
|
.status(400)
|
|
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
|
const d = parsed.data;
|
|
|
|
const update: Partial<typeof aiProvidersTable.$inferInsert> = {};
|
|
if (d.name !== undefined) update.name = d.name;
|
|
if (d.apiType !== undefined) update.apiType = d.apiType;
|
|
if (d.baseUrl !== undefined) update.baseUrl = d.baseUrl;
|
|
if (d.model !== undefined) update.model = d.model;
|
|
if (d.enabled !== undefined) update.enabled = d.enabled;
|
|
if (d.apiToken !== undefined && d.apiToken !== "")
|
|
update.apiToken = d.apiToken;
|
|
|
|
const [updated] = await db
|
|
.update(aiProvidersTable)
|
|
.set(update)
|
|
.where(eq(aiProvidersTable.id, params.data.id))
|
|
.returning();
|
|
if (!updated)
|
|
return res.status(404).json({ message: "Provider nicht gefunden" });
|
|
return res.json(UpdateProviderResponse.parse(serializeProvider(updated)));
|
|
});
|
|
|
|
router.delete("/providers/:id", async (req, res) => {
|
|
const params = DeleteProviderParams.safeParse(req.params);
|
|
if (!params.success) return res.status(400).json({ message: "Ungültige ID" });
|
|
await db
|
|
.delete(aiProvidersTable)
|
|
.where(eq(aiProvidersTable.id, params.data.id));
|
|
return res.status(204).send();
|
|
});
|
|
|
|
router.post("/providers/:id/test", async (req, res) => {
|
|
const params = TestProviderParams.safeParse(req.params);
|
|
if (!params.success) return res.status(400).json({ message: "Ungültige ID" });
|
|
const [provider] = await db
|
|
.select()
|
|
.from(aiProvidersTable)
|
|
.where(eq(aiProvidersTable.id, params.data.id));
|
|
if (!provider)
|
|
return res.status(404).json({ message: "Provider nicht gefunden" });
|
|
if (!provider.apiToken) {
|
|
return res.json(
|
|
TestProviderResponse.parse({
|
|
ok: false,
|
|
message: "Kein API-Token hinterlegt.",
|
|
}),
|
|
);
|
|
}
|
|
try {
|
|
const reply = await callProvider(
|
|
provider,
|
|
"Du bist ein Verbindungstest.",
|
|
'Antworte mit dem einzelnen Wort "OK".',
|
|
);
|
|
return res.json(
|
|
TestProviderResponse.parse({
|
|
ok: true,
|
|
message: `Verbindung erfolgreich. Antwort: ${reply.trim().slice(0, 80) || "(leer)"}`,
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
return res.json(
|
|
TestProviderResponse.parse({
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "Verbindung fehlgeschlagen.",
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
router.post("/providers/test-connection", async (req, res) => {
|
|
const parsed = TestProviderConnectionBody.safeParse(req.body);
|
|
if (!parsed.success)
|
|
return res
|
|
.status(400)
|
|
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
|
const d = parsed.data;
|
|
|
|
let token: string | null =
|
|
d.apiToken && d.apiToken !== "" ? d.apiToken : null;
|
|
if (!token && d.providerId !== undefined && d.providerId !== null) {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(aiProvidersTable)
|
|
.where(eq(aiProvidersTable.id, d.providerId));
|
|
if (existing?.apiToken) token = existing.apiToken;
|
|
}
|
|
if (!token) {
|
|
return res.json(
|
|
TestProviderResponse.parse({
|
|
ok: false,
|
|
message: "Kein API-Token angegeben.",
|
|
}),
|
|
);
|
|
}
|
|
|
|
const provider: AiProvider = {
|
|
id: d.providerId ?? 0,
|
|
name: "",
|
|
apiType: d.apiType,
|
|
baseUrl: d.baseUrl,
|
|
model: d.model,
|
|
apiToken: token,
|
|
enabled: true,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
const reply = await callProvider(
|
|
provider,
|
|
"Du bist ein Verbindungstest.",
|
|
'Antworte mit dem einzelnen Wort "OK".',
|
|
);
|
|
return res.json(
|
|
TestProviderResponse.parse({
|
|
ok: true,
|
|
message: `Verbindung erfolgreich. Antwort: ${reply.trim().slice(0, 80) || "(leer)"}`,
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
return res.json(
|
|
TestProviderResponse.parse({
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "Verbindung fehlgeschlagen.",
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
export default router;
|