diff --git a/server/src/index.ts b/server/src/index.ts index 78e26b1..ecf3b30 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import { dnsRouter } from "./routes/dns.js"; import { serversRouter } from "./routes/servers.js"; import { tasksRouter } from "./routes/tasks.js"; import { agentReportRouter } from "./routes/agentReport.js"; +import { integrationsRouter } from "./routes/integrations.js"; warnIfAuthNotConfigured(); await runMigrations(); @@ -65,6 +66,7 @@ app.use("/api/dns", dnsRouter); app.use("/api/servers", serversRouter); app.use("/api/tasks", tasksRouter); app.use("/api/agent/report", agentReportRouter); +app.use("/api/integrations", integrationsRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts new file mode 100644 index 0000000..a2e07a3 --- /dev/null +++ b/server/src/integrations/fieldSchemas.ts @@ -0,0 +1,49 @@ +import type { IntegrationType } from "../db/schema.js"; +import type { IntegrationField } from "./types.js"; + +/** + * Field schema per integration type. Only types with a working adapter appear + * here — the "Add integration" form only offers what's actually implemented. + */ +export const INTEGRATION_FIELDS: Partial> = { + tailscale: [ + { + key: "tailnet", + label: "Tailnet", + secret: false, + placeholder: "yourorg.github — or - for your default tailnet", + }, + { key: "apiKey", label: "API key", secret: true, type: "password" }, + ], +}; + +/** Fixed base URL per integration type, stored on the row for display/reference. */ +export const INTEGRATION_BASE_URLS: Partial> = { + tailscale: "https://api.tailscale.com", +}; + +export function splitIntegrationConfig( + type: IntegrationType, + input: Record, +): { secretFields: Record; nonSecretFields: Record } { + const fields = INTEGRATION_FIELDS[type] ?? []; + const secretFields: Record = {}; + const nonSecretFields: Record = {}; + for (const field of fields) { + if (input[field.key] === undefined) continue; + if (field.secret) secretFields[field.key] = input[field.key]; + else nonSecretFields[field.key] = input[field.key]; + } + return { secretFields, nonSecretFields }; +} + +export function validateIntegrationConfig( + type: IntegrationType, + merged: Record, +): string[] { + const fields = INTEGRATION_FIELDS[type] ?? []; + return fields + .filter((f) => f.type !== "checkbox") + .filter((f) => merged[f.key] === undefined || merged[f.key] === "") + .map((f) => f.key); +} diff --git a/server/src/integrations/loadIntegration.ts b/server/src/integrations/loadIntegration.ts new file mode 100644 index 0000000..2110ede --- /dev/null +++ b/server/src/integrations/loadIntegration.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { integrations, integrationCredentials } from "../db/schema.js"; +import { decryptSecret } from "../crypto.js"; +import { createIntegrationAdapter } from "./registry.js"; +import type { IntegrationConfig } from "./types.js"; + +export async function loadIntegrationConfig(integrationId: number) { + const [integration] = await db.select().from(integrations).where(eq(integrations.id, integrationId)).limit(1); + if (!integration) return null; + + let secretFields: Record = {}; + if (integration.credentialId) { + const [cred] = await db + .select() + .from(integrationCredentials) + .where(eq(integrationCredentials.id, integration.credentialId)) + .limit(1); + if (cred) secretFields = JSON.parse(decryptSecret(cred.encryptedSecret)); + } + const nonSecretFields: Record = integration.config ? JSON.parse(integration.config) : {}; + const merged: IntegrationConfig = { ...nonSecretFields, ...secretFields }; + + return { integration, config: merged }; +} + +export async function getIntegrationAdapter(integrationId: number) { + const loaded = await loadIntegrationConfig(integrationId); + if (!loaded) return null; + return { integration: loaded.integration, adapter: createIntegrationAdapter(loaded.integration.type, loaded.config) }; +} diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts new file mode 100644 index 0000000..861ecd9 --- /dev/null +++ b/server/src/integrations/registry.ts @@ -0,0 +1,12 @@ +import type { IntegrationType } from "../db/schema.js"; +import type { IntegrationConfig } from "./types.js"; +import { createTailscaleAdapter, type TailscaleAdapter } from "./tailscale/adapter.js"; + +export function createIntegrationAdapter(type: IntegrationType, config: IntegrationConfig): TailscaleAdapter { + switch (type) { + case "tailscale": + return createTailscaleAdapter(config as any); + default: + throw new Error(`Integration type "${type}" is not implemented yet`); + } +} diff --git a/server/src/integrations/tailscale/adapter.ts b/server/src/integrations/tailscale/adapter.ts new file mode 100644 index 0000000..69df01f --- /dev/null +++ b/server/src/integrations/tailscale/adapter.ts @@ -0,0 +1,99 @@ +/** + * Tailscale adapter — uses the Tailscale API. + * Requires config: tailnet, apiKey + * + * API docs: https://tailscale.com/api + */ + +const BASE = "https://api.tailscale.com"; + +export interface TailscaleConfig { + tailnet: string; + apiKey: string; +} + +export interface TailscaleDevice { + id: string; // stable numeric ID used for device-level API calls (authorize/delete) + nodeId: string; + hostname: string; + label: string; + addresses: string[]; + primaryAddress: string; + os: string; + lastSeen: string | null; + isExitNode: boolean; + authorized: boolean; + online: boolean | null; +} + +export interface TailscaleAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listDevices(): Promise; + setAuthorized(deviceId: string, authorized: boolean): Promise; + deleteDevice(deviceId: string): Promise; +} + +export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapter { + function headers() { + return { + Authorization: `Bearer ${config.apiKey}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + } + + function tailnetPath() { + return encodeURIComponent(config.tailnet || "-"); + } + + async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method, + headers: headers(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 204) return null; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.message || `Tailscale API error: HTTP ${res.status}`); + } + return data; + } + + async function listDevices(): Promise { + const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`); + return (data.devices || []).map((d: any) => ({ + id: d.id, + nodeId: d.nodeId || d.id, + hostname: d.hostname || "", + label: d.displayName || d.hostname || "", + addresses: d.addresses || [], + primaryAddress: d.addresses?.[0] || "", + os: d.os || "", + lastSeen: d.lastSeen || null, + isExitNode: !!d.isExitNode, + authorized: !!d.authorized, + online: d.online ?? null, + })); + } + + async function deleteDevice(deviceId: string): Promise { + await api("DELETE", `/api/v2/device/${deviceId}`); + } + + async function setAuthorized(deviceId: string, authorized: boolean): Promise { + await api("POST", `/api/v2/device/${deviceId}/authorized`, { authorized }); + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return { ping, listDevices, setAuthorized, deleteDevice }; +} diff --git a/server/src/integrations/types.ts b/server/src/integrations/types.ts new file mode 100644 index 0000000..f4150d6 --- /dev/null +++ b/server/src/integrations/types.ts @@ -0,0 +1,9 @@ +export interface IntegrationField { + key: string; + label: string; + secret: boolean; + type?: "text" | "password" | "checkbox"; + placeholder?: string; +} + +export type IntegrationConfig = Record; diff --git a/server/src/routes/agentReport.ts b/server/src/routes/agentReport.ts index 11090e0..303791e 100644 --- a/server/src/routes/agentReport.ts +++ b/server/src/routes/agentReport.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db } from "../db/client.js"; import { hashToken } from "../services/tokens.js"; import { syncServerTasks } from "../services/taskSync.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const agentReportRouter = Router(); @@ -24,7 +25,7 @@ const reportSchema = z.object({ ), }); -agentReportRouter.post("/", async (req, res) => { +agentReportRouter.post("/", asyncHandler(async (req, res) => { const authHeader = req.header("authorization") ?? ""; const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { @@ -59,4 +60,4 @@ agentReportRouter.post("/", async (req, res) => { }); res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length }); -}); +})); diff --git a/server/src/routes/auditLog.ts b/server/src/routes/auditLog.ts index c6fbd10..cffc0a3 100644 --- a/server/src/routes/auditLog.ts +++ b/server/src/routes/auditLog.ts @@ -3,13 +3,14 @@ import { desc } from "drizzle-orm"; import { db } from "../db/client.js"; import { auditLog } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const auditLogRouter = Router(); auditLogRouter.use(requireAuth, requireRole("operator")); -auditLogRouter.get("/", async (req, res) => { +auditLogRouter.get("/", asyncHandler(async (req, res) => { const limit = Math.min(Number(req.query.limit ?? 200), 500); const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit); res.json({ entries: rows }); -}); +})); diff --git a/server/src/routes/dns.ts b/server/src/routes/dns.ts index 575a5ed..c3abafc 100644 --- a/server/src/routes/dns.ts +++ b/server/src/routes/dns.ts @@ -13,6 +13,7 @@ import { } from "../dns/providerSchemas.js"; import { createDnsAdapter } from "../dns/registry.js"; import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const dnsRouter = Router(); @@ -26,7 +27,7 @@ dnsRouter.get("/provider-fields", (_req, res) => { // ─── Providers ─────────────────────────────────────────────────────────────── -dnsRouter.get("/providers", async (_req, res) => { +dnsRouter.get("/providers", asyncHandler(async (_req, res) => { const rows = await db .select({ id: dnsProviders.id, @@ -37,7 +38,7 @@ dnsRouter.get("/providers", async (_req, res) => { }) .from(dnsProviders); res.json({ providers: rows }); -}); +})); const configValueSchema = z.union([z.string(), z.boolean()]); @@ -47,7 +48,7 @@ const createProviderSchema = z.object({ config: z.record(configValueSchema), }); -dnsRouter.post("/providers", requireRole("admin"), async (req, res) => { +dnsRouter.post("/providers", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = createProviderSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -93,7 +94,7 @@ dnsRouter.post("/providers", requireRole("admin"), async (req, res) => { res.status(201).json({ provider: { id: created.id, providerType: created.providerType, name: created.name, enabled: created.enabled, createdAt: created.createdAt }, }); -}); +})); const updateProviderSchema = z.object({ name: z.string().min(1).max(200).optional(), @@ -101,7 +102,7 @@ const updateProviderSchema = z.object({ config: z.record(configValueSchema).optional(), }); -dnsRouter.patch("/providers/:id", requireRole("admin"), async (req, res) => { +dnsRouter.patch("/providers/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = updateProviderSchema.safeParse(req.body); if (!parsed.success) { @@ -182,9 +183,9 @@ dnsRouter.patch("/providers/:id", requireRole("admin"), async (req, res) => { res.json({ provider: { id: updated.id, providerType: updated.providerType, name: updated.name, enabled: updated.enabled, createdAt: updated.createdAt }, }); -}); +})); -dnsRouter.delete("/providers/:id", requireRole("admin"), async (req, res) => { +dnsRouter.delete("/providers/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(dnsProviders).where(eq(dnsProviders.id, id)).limit(1); if (!existing) { @@ -206,14 +207,14 @@ dnsRouter.delete("/providers/:id", requireRole("admin"), async (req, res) => { }); res.status(204).end(); -}); +})); const testProviderSchema = z.object({ providerType: z.enum(dnsProviderTypes), config: z.record(configValueSchema), }); -dnsRouter.post("/providers/test", requireRole("admin"), async (req, res) => { +dnsRouter.post("/providers/test", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = testProviderSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -225,11 +226,11 @@ dnsRouter.post("/providers/test", requireRole("admin"), async (req, res) => { } catch (err) { res.json({ ok: false, error: err instanceof Error ? err.message : String(err) }); } -}); +})); // ─── Zones ─────────────────────────────────────────────────────────────────── -dnsRouter.get("/providers/:id/zones", async (req, res) => { +dnsRouter.get("/providers/:id/zones", asyncHandler(async (req, res) => { const id = Number(req.params.id); const found = await getDnsAdapterForProvider(id); if (!found) return res.status(404).json({ error: "not_found" }); @@ -256,11 +257,11 @@ dnsRouter.get("/providers/:id/zones", async (req, res) => { } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); // ─── Records (cached) ──────────────────────────────────────────────────────── -dnsRouter.get("/providers/:id/zones/:zoneId/records", async (req, res) => { +dnsRouter.get("/providers/:id/zones/:zoneId/records", asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const zoneId = req.params.zoneId; @@ -275,11 +276,11 @@ dnsRouter.get("/providers/:id/zones/:zoneId/records", async (req, res) => { .limit(1); res.json({ records, syncedAt: zoneCache?.syncedAt ?? null }); -}); +})); const syncBodySchema = z.object({ zoneName: z.string().optional() }); -dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), async (req, res) => { +dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const zoneId = req.params.zoneId; const parsed = syncBodySchema.safeParse(req.body ?? {}); @@ -338,7 +339,7 @@ dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), asy } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); const recordInputSchema = z.object({ type: z.string().min(1), @@ -349,7 +350,7 @@ const recordInputSchema = z.object({ proxied: z.boolean().optional(), }); -dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), async (req, res) => { +dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const zoneId = req.params.zoneId; const parsed = recordInputSchema.safeParse(req.body); @@ -387,9 +388,9 @@ dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); -dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), async (req, res) => { +dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const { zoneId, recordId } = req.params; const parsed = recordInputSchema.safeParse(req.body); @@ -437,9 +438,9 @@ dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("ope } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); -dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), async (req, res) => { +dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), asyncHandler(async (req, res) => { const providerId = Number(req.params.id); const { zoneId, recordId } = req.params; @@ -471,4 +472,4 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole(" } catch (err) { res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } -}); +})); diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts new file mode 100644 index 0000000..40d6b90 --- /dev/null +++ b/server/src/routes/integrations.ts @@ -0,0 +1,334 @@ +import { Router, type Request, type Response } from "express"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { integrations, integrationCredentials, integrationTypes } from "../db/schema.js"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { encryptSecret } from "../crypto.js"; +import { + INTEGRATION_FIELDS, + INTEGRATION_BASE_URLS, + splitIntegrationConfig, + validateIntegrationConfig, +} from "../integrations/fieldSchemas.js"; +import { createIntegrationAdapter } from "../integrations/registry.js"; +import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; +import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const integrationsRouter = Router(); + +integrationsRouter.use(requireAuth); + +const IMPLEMENTED_TYPES = Object.keys(INTEGRATION_FIELDS) as (keyof typeof INTEGRATION_FIELDS)[]; + +integrationsRouter.get("/fields", (_req, res) => { + res.json({ fields: INTEGRATION_FIELDS }); +}); + +integrationsRouter.get("/", asyncHandler(async (_req, res) => { + const rows = await db + .select({ + id: integrations.id, + type: integrations.type, + name: integrations.name, + baseUrl: integrations.baseUrl, + enabled: integrations.enabled, + createdAt: integrations.createdAt, + }) + .from(integrations); + res.json({ integrations: rows }); +})); + +const configValueSchema = z.union([z.string(), z.boolean()]); + +const createIntegrationSchema = z.object({ + type: z.enum(integrationTypes), + name: z.string().min(1).max(200), + config: z.record(configValueSchema), +}); + +integrationsRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = createIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + const { type, name, config } = parsed.data; + + if (!IMPLEMENTED_TYPES.includes(type)) { + return res.status(400).json({ error: "not_implemented", message: `Integration type "${type}" isn't available yet.` }); + } + + const missing = validateIntegrationConfig(type, config); + if (missing.length > 0) { + return res.status(400).json({ error: "missing_fields", fields: missing }); + } + + const { secretFields, nonSecretFields } = splitIntegrationConfig(type, config); + + let credentialId: number | null = null; + if (Object.keys(secretFields).length > 0) { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${type}:${name}`, encryptedSecret: encryptSecret(JSON.stringify(secretFields)) }) + .returning(); + credentialId = cred.id; + } + + const [created] = await db + .insert(integrations) + .values({ + type, + name, + baseUrl: INTEGRATION_BASE_URLS[type] ?? "", + credentialId, + config: JSON.stringify(nonSecretFields), + enabled: true, + }) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "create", + targetType: "integration", + targetId: created.id, + detail: { type, name }, + }); + + res.status(201).json({ + integration: { + id: created.id, + type: created.type, + name: created.name, + baseUrl: created.baseUrl, + enabled: created.enabled, + createdAt: created.createdAt, + }, + }); +})); + +const updateIntegrationSchema = z.object({ + name: z.string().min(1).max(200).optional(), + enabled: z.boolean().optional(), + config: z.record(configValueSchema).optional(), +}); + +integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const parsed = updateIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); + if (!existing) { + return res.status(404).json({ error: "not_found" }); + } + + let credentialId = existing.credentialId; + let configJson = existing.config; + + if (parsed.data.config) { + const loaded = await loadIntegrationConfig(id); + const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config); + const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields }; + for (const field of INTEGRATION_FIELDS[existing.type] ?? []) { + if (field.secret) delete (mergedNonSecret as Record)[field.key]; + } + configJson = JSON.stringify(mergedNonSecret); + + if (Object.keys(secretFields).length > 0) { + const existingSecrets: Record = {}; + const fields = (INTEGRATION_FIELDS[existing.type] ?? []).filter((f) => f.secret); + for (const f of fields) { + if (loaded?.config[f.key] !== undefined) existingSecrets[f.key] = loaded.config[f.key]!; + } + const mergedSecrets = { ...existingSecrets, ...secretFields }; + const encrypted = encryptSecret(JSON.stringify(mergedSecrets)); + if (existing.credentialId) { + await db.update(integrationCredentials).set({ encryptedSecret: encrypted }).where(eq(integrationCredentials.id, existing.credentialId)); + } else { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${existing.type}:${existing.name}`, encryptedSecret: encrypted }) + .returning(); + credentialId = cred.id; + } + } + } + + const [updated] = await db + .update(integrations) + .set({ + name: parsed.data.name ?? existing.name, + enabled: parsed.data.enabled ?? existing.enabled, + credentialId, + config: configJson, + }) + .where(eq(integrations.id, id)) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "update", + targetType: "integration", + targetId: id, + detail: { name: updated.name }, + }); + + res.json({ + integration: { + id: updated.id, + type: updated.type, + name: updated.name, + baseUrl: updated.baseUrl, + enabled: updated.enabled, + createdAt: updated.createdAt, + }, + }); +})); + +integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); + if (!existing) { + return res.status(404).json({ error: "not_found" }); + } + + await db.delete(integrations).where(eq(integrations.id, id)); + if (existing.credentialId) { + await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId)); + } + + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "delete", + targetType: "integration", + targetId: id, + detail: { name: existing.name }, + }); + + res.status(204).end(); +})); + +const testIntegrationSchema = z.object({ + type: z.enum(integrationTypes), + config: z.record(configValueSchema), +}); + +integrationsRouter.post("/test", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = testIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + if (!IMPLEMENTED_TYPES.includes(parsed.data.type)) { + return res.status(400).json({ error: "not_implemented" }); + } + const adapter = createIntegrationAdapter(parsed.data.type, parsed.data.config); + const result = await adapter.ping(); + res.json(result); +})); + +// ─── Tailscale ─────────────────────────────────────────────────────────────── + +/** + * Loads the integration row and checks its type BEFORE constructing an + * adapter — createIntegrationAdapter() throws for any not-yet-implemented + * type, and that must never happen for an existing non-tailscale row (e.g. a + * Proxmox row someone points at these Tailscale-only routes). + */ +async function requireTailscaleAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "tailscale") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createTailscaleAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/tailscale/devices", asyncHandler(async (req, res) => { + const found = await requireTailscaleAdapter(req, res); + if (!found) return; + + try { + const devices = await found.adapter.listDevices(); + res.json({ + devices, + summary: { + total: devices.length, + online: devices.filter((d) => d.online).length, + unauthorized: devices.filter((d) => !d.authorized).length, + }, + }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +})); + +const authorizeSchema = z.object({ authorized: z.boolean() }); + +integrationsRouter.post( + "/:id/tailscale/devices/:deviceId/authorize", + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireTailscaleAdapter(req, res); + if (!found) return; + + const parsed = authorizeSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + try { + await found.adapter.setAuthorized(req.params.deviceId, parsed.data.authorized); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: parsed.data.authorized ? "authorize_device" : "deauthorize_device", + targetType: "tailscale_device", + targetId: req.params.deviceId, + detail: { integrationId: found.integration.id }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), +); + +integrationsRouter.delete( + "/:id/tailscale/devices/:deviceId", + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireTailscaleAdapter(req, res); + if (!found) return; + + try { + await found.adapter.deleteDevice(req.params.deviceId); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "remove_device", + targetType: "tailscale_device", + targetId: req.params.deviceId, + detail: { integrationId: found.integration.id }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), +); diff --git a/server/src/routes/ipam.ts b/server/src/routes/ipam.ts index 0cbced3..5bc7018 100644 --- a/server/src/routes/ipam.ts +++ b/server/src/routes/ipam.ts @@ -6,16 +6,17 @@ import { db } from "../db/client.js"; import { ipamEntries } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const ipamRouter = Router(); ipamRouter.use(requireAuth); -ipamRouter.get("/", async (_req, res) => { +ipamRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress); // matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference. res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) }); -}); +})); const createInput = z.object({ ipAddress: z.string().refine((v) => isIP(v) !== 0, "Must be a valid IPv4 or IPv6 address"), @@ -27,7 +28,7 @@ const createInput = z.object({ const updateInput = createInput.omit({ ipAddress: true }).partial(); -ipamRouter.post("/", requireRole("operator"), async (req, res) => { +ipamRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = createInput.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -54,9 +55,9 @@ ipamRouter.post("/", requireRole("operator"), async (req, res) => { }); res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } }); -}); +})); -ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => { +ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = updateInput.safeParse(req.body); if (!parsed.success) { @@ -84,9 +85,9 @@ ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => { }); res.json({ entry: { ...updated, matchingDnsRecords: [] } }); -}); +})); -ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => { +ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1); if (!existing) { @@ -105,4 +106,4 @@ ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 8259d4a..506e255 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -6,17 +6,18 @@ import { secrets, secretTypes } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; import { computeSecretStatus } from "../services/secretStatus.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const secretsRouter = Router(); secretsRouter.use(requireAuth); -secretsRouter.get("/", async (_req, res) => { +secretsRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.select().from(secrets).orderBy(secrets.expiryDate); res.json({ secrets: rows.map((s) => ({ ...s, ...computeSecretStatus(s.expiryDate, s.warnDays) })), }); -}); +})); const secretInput = z.object({ name: z.string().min(1).max(200), @@ -27,7 +28,7 @@ const secretInput = z.object({ notes: z.string().max(4000).optional(), }); -secretsRouter.post("/", requireRole("operator"), async (req, res) => { +secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = secretInput.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -45,9 +46,9 @@ secretsRouter.post("/", requireRole("operator"), async (req, res) => { }); res.status(201).json({ secret: { ...created, ...computeSecretStatus(created.expiryDate, created.warnDays) } }); -}); +})); -secretsRouter.patch("/:id", requireRole("operator"), async (req, res) => { +secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = secretInput.partial().safeParse(req.body); if (!parsed.success) { @@ -75,9 +76,9 @@ secretsRouter.patch("/:id", requireRole("operator"), async (req, res) => { }); res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } }); -}); +})); -secretsRouter.delete("/:id", requireRole("operator"), async (req, res) => { +secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); if (!existing) { @@ -96,4 +97,4 @@ secretsRouter.delete("/:id", requireRole("operator"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 365d099..7b865a2 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -6,6 +6,7 @@ import { servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { generateApiToken } from "../services/tokens.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const serversRouter = Router(); serversRouter.use(requireAuth); @@ -17,14 +18,14 @@ const createServerSchema = z.object({ description: z.string().max(500).optional(), }); -serversRouter.get("/", async (_req, res) => { +serversRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] }); res.json({ servers: rows.map(({ apiTokenHash, ...rest }) => rest), }); -}); +})); -serversRouter.post("/", requireRole("admin"), async (req, res) => { +serversRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = createServerSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -56,9 +57,9 @@ serversRouter.post("/", requireRole("admin"), async (req, res) => { const { apiTokenHash, ...serverOut } = created; // The full token is only ever shown once, at creation time. res.status(201).json({ server: serverOut, token }); -}); +})); -serversRouter.post("/:id/rotate-token", requireRole("admin"), async (req, res) => { +serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -82,9 +83,9 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), async (req, res) = const { apiTokenHash, ...serverOut } = updated; res.json({ server: serverOut, token }); -}); +})); -serversRouter.delete("/:id", requireRole("admin"), async (req, res) => { +serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -101,4 +102,4 @@ serversRouter.delete("/:id", requireRole("admin"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 0a675bd..98f196c 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -5,6 +5,7 @@ import { db } from "../db/client.js"; import { scheduledTasks, servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const tasksRouter = Router(); tasksRouter.use(requireAuth); @@ -26,7 +27,7 @@ const TASK_COLUMNS = { lastSeenAt: scheduledTasks.lastSeenAt, }; -tasksRouter.get("/", async (req, res) => { +tasksRouter.get("/", asyncHandler(async (req, res) => { const serverId = req.query.serverId ? Number(req.query.serverId) : undefined; const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined; const search = typeof req.query.search === "string" ? req.query.search.trim() : undefined; @@ -55,7 +56,7 @@ tasksRouter.get("/", async (req, res) => { .orderBy(servers.name, scheduledTasks.scheduleType, scheduledTasks.name); res.json({ tasks: rows }); -}); +})); const manualTaskSchema = z.object({ serverId: z.number().int(), @@ -67,7 +68,7 @@ const manualTaskSchema = z.object({ enabled: z.boolean().optional(), }); -tasksRouter.post("/", requireRole("operator"), async (req, res) => { +tasksRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = manualTaskSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); @@ -110,11 +111,11 @@ tasksRouter.post("/", requireRole("operator"), async (req, res) => { .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(eq(scheduledTasks.id, created.id)); res.status(201).json({ task }); -}); +})); const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial(); -tasksRouter.patch("/:id", requireRole("operator"), async (req, res) => { +tasksRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -149,9 +150,9 @@ tasksRouter.patch("/:id", requireRole("operator"), async (req, res) => { .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(eq(scheduledTasks.id, id)); res.json({ task }); -}); +})); -tasksRouter.delete("/:id", requireRole("operator"), async (req, res) => { +tasksRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); @@ -173,4 +174,4 @@ tasksRouter.delete("/:id", requireRole("operator"), async (req, res) => { }); res.status(204).end(); -}); +})); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 6c06519..a7e3482 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -5,21 +5,22 @@ import { db } from "../db/client.js"; import { users, userRoles } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; export const usersRouter = Router(); usersRouter.use(requireAuth, requireRole("admin")); -usersRouter.get("/", async (_req, res) => { +usersRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.select().from(users).orderBy(users.createdAt); res.json({ users: rows }); -}); +})); const updateRoleSchema = z.object({ role: z.enum(userRoles), }); -usersRouter.patch("/:id/role", async (req, res) => { +usersRouter.patch("/:id/role", asyncHandler(async (req, res) => { const id = Number(req.params.id); const parsed = updateRoleSchema.safeParse(req.body); if (!parsed.success) { @@ -58,4 +59,4 @@ usersRouter.patch("/:id/role", async (req, res) => { }); res.json({ user: updated }); -}); +})); diff --git a/server/src/utils/asyncHandler.ts b/server/src/utils/asyncHandler.ts new file mode 100644 index 0000000..89ae783 --- /dev/null +++ b/server/src/utils/asyncHandler.ts @@ -0,0 +1,16 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +/** + * Wraps an async route handler so a rejected promise (or a thrown error before + * the handler's own try/catch even starts, e.g. in a shared helper it calls) + * is forwarded to Express's error middleware instead of becoming an unhandled + * rejection — which, on modern Node, crashes the whole process rather than + * just failing the one request. + */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +): RequestHandler { + return (req, res, next) => { + fn(req, res, next).catch(next); + }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index ce2fce2..ff1a7c4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; import ServersTasks from "./pages/ServersTasks"; +import Integrations from "./pages/Integrations"; import ComingSoon from "./pages/ComingSoon"; import AppShell from "./layout/AppShell"; @@ -62,7 +63,7 @@ export default function App() { } /> } /> } /> - } /> + } /> (`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/tasks/${id}`, { method: "DELETE" }), }, + integrations: { + fields: () => request<{ fields: Partial> }>("/api/integrations/fields"), + list: () => request<{ integrations: IntegrationSummary[] }>("/api/integrations"), + create: (data: { type: IntegrationType; name: string; config: Record }) => + request<{ integration: IntegrationSummary }>("/api/integrations", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: { name?: string; enabled?: boolean; config?: Record }) => + request<{ integration: IntegrationSummary }>(`/api/integrations/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), + remove: (id: number) => request(`/api/integrations/${id}`, { method: "DELETE" }), + test: (data: { type: IntegrationType; config: Record }) => + request<{ ok: boolean; latencyMs?: number; error?: string }>("/api/integrations/test", { + method: "POST", + body: JSON.stringify(data), + }), + tailscale: { + devices: (integrationId: number) => + request(`/api/integrations/${integrationId}/tailscale/devices`), + setAuthorized: (integrationId: number, deviceId: string, authorized: boolean) => + request(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}/authorize`, { + method: "POST", + body: JSON.stringify({ authorized }), + }), + remove: (integrationId: number, deviceId: string) => + request(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}`, { + method: "DELETE", + }), + }, + }, }; diff --git a/web/src/components/IntegrationForm.tsx b/web/src/components/IntegrationForm.tsx new file mode 100644 index 0000000..6e392a8 --- /dev/null +++ b/web/src/components/IntegrationForm.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { api, type IntegrationField, type IntegrationType } from "../api/client"; + +const TYPE_LABELS: Record = { + tailscale: "Tailscale", + proxmox: "Proxmox", + synology: "Synology", + semaphore: "Semaphore", + gitea: "Gitea", + dockhand: "Dockhand", +}; + +export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) { + const [allFields, setAllFields] = useState> | null>(null); + const [type, setType] = useState(null); + const [name, setName] = useState(""); + const [values, setValues] = useState>({}); + const [testResult, setTestResult] = useState<{ ok: boolean; latencyMs?: number; error?: string } | null>(null); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.integrations.fields().then((res) => { + setAllFields(res.fields); + const firstType = Object.keys(res.fields)[0] as IntegrationType | undefined; + if (firstType) setType(firstType); + }); + }, []); + + useEffect(() => { + setValues({}); + setTestResult(null); + }, [type]); + + const availableTypes = allFields ? (Object.keys(allFields) as IntegrationType[]) : []; + const fields = (type && allFields?.[type]) ?? []; + + function setField(key: string, value: string | boolean) { + setValues((v) => ({ ...v, [key]: value })); + } + + async function testConnection() { + if (!type) return; + setTesting(true); + setTestResult(null); + try { + const result = await api.integrations.test({ type, config: values }); + setTestResult(result); + } catch (err) { + setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } finally { + setTesting(false); + } + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!type) return; + setError(null); + setSaving(true); + try { + await api.integrations.create({ type, name, config: values }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + if (allFields && availableTypes.length === 0) { + return ( +
+
+ No integration types are implemented yet. +
+
+ ); + } + + return ( +
+
+

Add an integration

+
+
+
+ {error && ( +
+
{error}
+
+ )} +
+ + +
+
+ + setName(e.target.value)} + /> +
+ + {fields.map((field) => ( +
+ {field.type === "checkbox" ? ( + + ) : ( + <> + + setField(field.key, e.target.value)} + /> + + )} +
+ ))} + + {testResult && ( +
+ {testResult.ok ? ( +
+ Connection OK{testResult.latencyMs !== undefined ? ` (${testResult.latencyMs}ms)` : ""}. +
+ ) : ( +
Connection failed: {testResult.error}
+ )} +
+ )} +
+
+ + + +
+
+
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7a761f3..8ad39a3 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,6 +1,36 @@ -import type { CurrentUser } from "../api/client"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type IntegrationSummary, type IntegrationType, type TailscaleDevicesResponse } from "../api/client"; + +const WIDGETS: { type: IntegrationType; label: string }[] = [ + { type: "proxmox", label: "Proxmox" }, + { type: "synology", label: "Synology NAS" }, + { type: "semaphore", label: "Semaphore" }, + { type: "tailscale", label: "Tailscale" }, + { type: "gitea", label: "Gitea" }, + { type: "dockhand", label: "Dockhand / Docker" }, +]; export default function Dashboard({ user }: { user: CurrentUser }) { + const [integrations, setIntegrations] = useState(null); + const [tailscaleSummary, setTailscaleSummary] = useState(null); + + useEffect(() => { + api.integrations.list().then((res) => setIntegrations(res.integrations)); + }, []); + + useEffect(() => { + const tailscale = integrations?.find((i) => i.type === "tailscale" && i.enabled); + if (!tailscale) { + setTailscaleSummary(null); + return; + } + api.integrations.tailscale + .devices(tailscale.id) + .then((res) => setTailscaleSummary(res.summary)) + .catch(() => setTailscaleSummary(null)); + }, [integrations]); + return ( <>
@@ -14,30 +44,53 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
- {[ - "Proxmox", - "Synology NAS", - "Semaphore", - "Tailscale", - "Gitea", - "Dockhand / Docker", - ].map((name) => ( -
-
-
-
-
{name}
-
- Not connected + {WIDGETS.map(({ type, label }) => { + const integration = integrations?.find((i) => i.type === type && i.enabled); + const isLive = type === "tailscale" && integration && tailscaleSummary; + + return ( +
+
+
+
+
{label}
+
+ {isLive ? ( + Connected + ) : integration ? ( + Connecting… + ) : ( + Not connected + )} +
-
-
- Configure this integration from the Integrations page. + {isLive ? ( +
+
+ {tailscaleSummary!.online}/{tailscaleSummary!.total} +
+
devices online
+ {tailscaleSummary!.unauthorized > 0 && ( +
{tailscaleSummary!.unauthorized} awaiting authorization
+ )} +
+ ) : ( +
+ {integration ? ( + "Fetching status…" + ) : ( + <> + Configure this integration from the{" "} + Integrations page. + + )} +
+ )}
-
- ))} + ); + })}
); diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx new file mode 100644 index 0000000..97b5070 --- /dev/null +++ b/web/src/pages/Integrations.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from "react"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type IntegrationType, + type TailscaleDevice, + type TailscaleDevicesResponse, +} from "../api/client"; +import IntegrationForm from "../components/IntegrationForm"; + +const TYPE_LABELS: Record = { + tailscale: "Tailscale", + proxmox: "Proxmox", + synology: "Synology", + semaphore: "Semaphore", + gitea: "Gitea", + dockhand: "Dockhand", +}; + +export default function Integrations({ user }: { user: CurrentUser }) { + const isAdmin = user.role === "admin"; + const canEdit = user.role === "admin" || user.role === "operator"; + + const [integrations, setIntegrations] = useState(null); + const [managing, setManaging] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + + const [tailscaleData, setTailscaleData] = useState(null); + const [tailscaleError, setTailscaleError] = useState(null); + const [loadingDevices, setLoadingDevices] = useState(false); + + function loadIntegrations() { + api.integrations + .list() + .then((res) => { + setIntegrations(res.integrations); + if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id); + }) + .catch((err) => setError(String(err))); + } + + useEffect(loadIntegrations, []); + + const selected = integrations?.find((i) => i.id === selectedId) ?? null; + + function loadTailscaleDevices(id: number) { + setLoadingDevices(true); + setTailscaleError(null); + api.integrations.tailscale + .devices(id) + .then((res) => setTailscaleData(res)) + .catch((err) => setTailscaleError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoadingDevices(false)); + } + + useEffect(() => { + if (selected?.type === "tailscale" && selected.enabled && !managing) { + loadTailscaleDevices(selected.id); + } else { + setTailscaleData(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId, managing, integrations]); + + async function toggleEnabled(i: IntegrationSummary) { + try { + await api.integrations.update(i.id, { enabled: !i.enabled }); + loadIntegrations(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function removeIntegration(i: IntegrationSummary) { + if (!confirm(`Delete integration "${i.name}"?`)) return; + try { + await api.integrations.remove(i.id); + if (selectedId === i.id) setSelectedId(null); + loadIntegrations(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) { + if (!selectedId) return; + try { + await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized); + loadTailscaleDevices(selectedId); + } catch (err) { + setTailscaleError(err instanceof Error ? err.message : String(err)); + } + } + + async function removeDevice(device: TailscaleDevice) { + if (!selectedId) return; + if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return; + try { + await api.integrations.tailscale.remove(selectedId, device.id); + loadTailscaleDevices(selectedId); + } catch (err) { + setTailscaleError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + <> +
+

Integrations

+ {isAdmin && ( + + )} +
+ {error &&
{error}
} + + {managing ? ( + <> + setManaging(false)} /> +
+
+ + + + + + + + + + + {integrations?.map((i) => ( + + + + + + + ))} + {integrations?.length === 0 && ( + + + + )} + +
NameTypeStatusActions
{i.name}{TYPE_LABELS[i.type]} + + + +
+ No integrations configured yet. +
+
+
+ + ) : ( + <> + {integrations?.length === 0 ? ( +
+
+ No integrations configured yet. + {isAdmin && ( + <> + {" "} + + . + + )} +
+
+ ) : ( + <> +
+ +
+ + {selected?.type === "tailscale" ? ( +
+
+

+ Devices + {tailscaleData && ( + + {tailscaleData.summary.online}/{tailscaleData.summary.total} online + {tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`} + + )} +

+
+ +
+
+ {tailscaleError &&
{tailscaleError}
} +
+ + + + + + + + + {canEdit && } + + + + {tailscaleData?.devices.map((d) => ( + + + + + + + {canEdit && ( + + )} + + ))} + {tailscaleData?.devices.length === 0 && ( + + + + )} + +
DeviceAddressOSStatusLast seenActions
+ {d.label || d.hostname} + {d.isExitNode && Exit node} + {d.primaryAddress}{d.os || "—"} + {d.online ? ( + Online + ) : ( + Offline + )} + {!d.authorized && Unauthorized} + {d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"} +
+ {d.authorized ? ( + + ) : ( + + )} + +
+
+ No devices in this tailnet. +
+
+
+ ) : ( +
+
+ {selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."} +
+
+ )} + + )} + + )} + + ); +}