calcomblock/web/settings.tsx
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

516 lines
19 KiB
TypeScript

import { useState, useCallback, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Button,
Alert,
AlertDescription,
Badge,
ScrollArea,
Loader2,
CheckCircle2,
XCircle,
AlertCircle,
Eye,
EyeOff,
Key,
Zap,
Settings,
Send,
Copy,
RefreshCw,
toast,
t,
useAlertDialog,
} from "@block-ninja/ui";
type PluginSettingsProps = {
pluginName: string;
};
type SettingsData = {
api_key_configured: boolean;
api_key_masked?: string;
webhook_url?: string;
webhook_secret_configured?: boolean;
webhook_secret_masked?: string;
last_event_at?: string;
last_event_type?: string;
};
type TestResult = {
success: boolean;
error?: string;
event_types?: { id: number; title: string; slug: string }[];
};
const copyToClipboard = async (text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
toast.success(t("calcom.settings.copied", "Copied to clipboard"));
} catch (_error) {
// Clipboard API may be unavailable (non-secure context).
toast.error(t("calcom.settings.copyFailed", "Could not copy to clipboard"));
}
};
const formatRelativeTime = (iso: string): string => {
const parsed = Date.parse(iso);
if (Number.isNaN(parsed)) return iso;
const diffMs = Date.now() - parsed;
const minutes = Math.round(diffMs / 60000);
if (minutes < 1) return t("calcom.settings.relative.justNow", "just now");
if (minutes < 60) {
return minutes === 1 ? t("calcom.settings.relative.minute", "1 minute ago") : t("calcom.settings.relative.minutes", "{count} minutes ago").replace("{count}", String(minutes));
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return hours === 1 ? t("calcom.settings.relative.hour", "1 hour ago") : t("calcom.settings.relative.hours", "{count} hours ago").replace("{count}", String(hours));
}
const days = Math.round(hours / 24);
return days === 1 ? t("calcom.settings.relative.day", "1 day ago") : t("calcom.settings.relative.days", "{count} days ago").replace("{count}", String(days));
};
function CalcomSettings({ pluginName }: PluginSettingsProps) {
const { confirm } = useAlertDialog();
const [apiKey, setApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [rotating, setRotating] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [settings, setSettings] = useState<SettingsData | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const baseUrl = `/api/plugins/${pluginName}`;
// Follow-up: when @block-ninja/ui re-exports useQuery from connect-query
// AND this plugin's settings endpoints move to proto-backed RPCs, swap this
// useEffect + fetch pair for useQuery with a staleTime so re-opening the
// settings panel does not refetch. Today neither precondition holds (no
// .proto for this plugin, no tanstack-query primitives re-exported), so
// the raw fetch stays.
const loadSettings = useCallback(async () => {
try {
const res = await fetch(`${baseUrl}/settings`);
if (res.ok) {
const data = await res.json();
setSettings(data);
}
} catch (_error) {
console.error("Failed to load settings");
} finally {
setLoading(false);
}
}, [baseUrl]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
// Save API key
const handleSave = async () => {
if (!apiKey.trim()) {
setSaveMessage({ type: "error", text: t("calcom.settings.apiKey.errorEmpty", "Please enter an API key") });
return;
}
setSaving(true);
setSaveMessage(null);
setTestResult(null);
try {
const res = await fetch(`${baseUrl}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
if (res.ok) {
// Reload settings to pick up the auto-generated webhook secret.
await loadSettings();
setApiKey("");
setSaveMessage({ type: "success", text: t("calcom.settings.apiKey.saved", "API key saved successfully") });
} else {
const text = await res.text();
setSaveMessage({ type: "error", text: text || t("calcom.settings.apiKey.saveFailed", "Failed to save API key") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setSaving(false);
}
};
// Test connection
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`${baseUrl}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data);
} catch (_error) {
setTestResult({ success: false, error: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setTesting(false);
}
};
// Clear API key
const handleClear = async () => {
setSaving(true);
setSaveMessage(null);
try {
const res = await fetch(`${baseUrl}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: "" }),
});
if (res.ok) {
await loadSettings();
setTestResult(null);
setSaveMessage({ type: "success", text: t("calcom.settings.apiKey.removed", "API key removed") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.apiKey.clearFailed", "Failed to clear API key") });
} finally {
setSaving(false);
}
};
// Rotate webhook secret
const handleRotate = async () => {
const confirmed = await confirm({
title: t("calcom.settings.webhook.rotateConfirmTitle", "Rotate webhook secret?"),
message: t("calcom.settings.webhook.rotateConfirmMessage", "This invalidates the existing secret immediately. You'll need to update Cal.com to keep webhooks flowing."),
confirmLabel: t("calcom.settings.webhook.rotateConfirmLabel", "Rotate"),
variant: "destructive",
});
if (!confirmed) return;
setRotating(true);
setSaveMessage(null);
try {
const res = await fetch(`${baseUrl}/settings/rotate-webhook-secret`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setSettings((prev) => (prev ? { ...prev, webhook_secret_masked: data.webhook_secret_masked, webhook_secret_configured: true } : prev));
const successMsg = t("calcom.settings.webhook.rotated", "Webhook secret rotated. Update it in Cal.com.");
setSaveMessage({ type: "success", text: successMsg });
toast.success(successMsg);
} else {
setSaveMessage({ type: "error", text: t("calcom.settings.webhook.rotateFailed", "Failed to rotate webhook secret") });
}
} catch (_error) {
setSaveMessage({ type: "error", text: t("calcom.settings.serverError", "Failed to connect to server") });
} finally {
setRotating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const apiKeyVisibilityLabel = showApiKey ? t("calcom.settings.toggleVisibility.hide", "Hide") : t("calcom.settings.toggleVisibility.show", "Show");
const secretVisibilityLabel = showSecret ? t("calcom.settings.toggleVisibility.hide", "Hide") : t("calcom.settings.toggleVisibility.show", "Show");
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold flex items-center gap-2">
<Settings className="h-5 w-5" />
{t("calcom.settings.heading", "Cal.com Integration")}
</h2>
<p className="text-sm text-muted-foreground">{t("calcom.settings.headingDescription", "Configure your Cal.com API key to enable booking functionality")}</p>
</div>
{/* Current Status */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Key className="h-4 w-4" />
{t("calcom.settings.apiKey.title", "API Key Status")}
</CardTitle>
<CardDescription>{t("calcom.settings.apiKey.description", "Your Cal.com API key is required for the booking widget to function")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{settings?.api_key_configured ?
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
<div className="flex items-center gap-3">
<Badge variant="default" className="gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
{t("calcom.settings.apiKey.configuredLabel", "Configured")}
</Badge>
<p className="text-xs text-muted-foreground font-mono">{settings.api_key_masked || t("calcom.settings.apiKey.maskedFallback", "***configured***")}</p>
</div>
<Button action="delete" entity="plugin-setting" variant="ghost" size="sm" onClick={handleClear} disabled={saving}>
{t("calcom.settings.apiKey.remove", "Remove")}
</Button>
</div>
: <div className="flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
<Badge variant="outline" className="gap-1.5">
<XCircle className="h-3.5 w-3.5" />
{t("calcom.settings.apiKey.notConfiguredLabel", "Not configured")}
</Badge>
<p className="text-xs text-muted-foreground">{t("calcom.settings.apiKey.notConfiguredHelp", "Enter your Cal.com API key below to enable booking")}</p>
</div>
}
{/* API Key Input */}
<div className="space-y-2">
<Label htmlFor="apiKey">{settings?.api_key_configured ? t("calcom.settings.apiKey.updateLabel", "Update API Key") : t("calcom.settings.apiKey.label", "API Key")}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="apiKey"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t("calcom.settings.apiKey.placeholder", "cal_live_...")}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
action="toggle"
entity="api-key-visibility"
onClick={() => setShowApiKey(!showApiKey)}
aria-label={apiKeyVisibilityLabel}
className="absolute right-3 top-1/2 -translate-y-1/2 h-auto p-0 text-muted-foreground hover:bg-transparent hover:text-foreground">
{showApiKey ?
<EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
</Button>
</div>
<Button action="save" entity="plugin-setting" onClick={handleSave} disabled={saving || !apiKey.trim()}>
{saving ?
<Loader2 className="h-4 w-4 animate-spin" />
: t("calcom.settings.apiKey.save", "Save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("calcom.settings.apiKey.helpPrefix", "Get your API key from")}{" "}
<a href="https://app.cal.com/settings/developer/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{t("calcom.settings.apiKey.helpLink", "Cal.com API Settings")}
</a>
</p>
</div>
{/* Save Message */}
{saveMessage && (
<Alert variant={saveMessage.type === "error" ? "destructive" : "default"}>
<AlertDescription>{saveMessage.text}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Test Connection */}
{settings?.api_key_configured && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Zap className="h-4 w-4" />
{t("calcom.settings.test.title", "Test Connection")}
</CardTitle>
<CardDescription>{t("calcom.settings.test.description", "Verify your API key works and see available event types")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button action="settings" entity="plugin-connection" onClick={handleTest} disabled={testing}>
{testing ?
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("calcom.settings.test.testing", "Testing...")}
</>
: t("calcom.settings.test.button", "Test Connection")}
</Button>
{testResult && (
<div className="space-y-3">
{testResult.success ?
<>
<Alert>
<CheckCircle2 className="h-4 w-4 text-success" />
<AlertDescription className="ml-2">
{t("calcom.settings.test.successPrefix", "Connection successful! Found")} {testResult.event_types?.length || 0}{" "}
{t("calcom.settings.test.successSuffix", "event types.")}
</AlertDescription>
</Alert>
{testResult.event_types && testResult.event_types.length > 0 && (
<ScrollArea className="max-h-96 rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="text-left px-3 py-2 font-medium">{t("calcom.settings.test.tableEventType", "Event Type")}</th>
<th className="text-left px-3 py-2 font-medium">{t("calcom.settings.test.tableSlug", "Slug")}</th>
</tr>
</thead>
<tbody>
{testResult.event_types.map((et) => (
<tr key={et.id} className="border-t">
<td className="px-3 py-2">{et.title}</td>
<td className="px-3 py-2 font-mono text-xs">{et.slug}</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
)}
</>
: <Alert variant="destructive">
<XCircle className="h-4 w-4" />
<AlertDescription className="ml-2">{testResult.error || t("calcom.settings.test.failure", "Connection failed")}</AlertDescription>
</Alert>
}
</div>
)}
</CardContent>
</Card>
)}
{/* Webhook */}
{settings?.api_key_configured && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Send className="h-4 w-4" />
{t("calcom.settings.webhook.title", "Webhook")}
</CardTitle>
<CardDescription>{t("calcom.settings.webhook.description", "Configure this URL in Cal.com → Settings → Developer → Webhooks to receive booking events.")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhookUrl">{t("calcom.settings.webhook.urlLabel", "Webhook URL")}</Label>
<div className="flex gap-2">
<Input id="webhookUrl" readOnly value={settings?.webhook_url || ""} className="flex-1 font-mono text-xs" />
<Button
action="copy"
entity="webhook-url"
variant="outline"
size="sm"
aria-label={t("calcom.settings.webhook.copyUrl", "Copy webhook URL")}
onClick={() => copyToClipboard(settings?.webhook_url || "")}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="webhookSecret">{t("calcom.settings.webhook.secretLabel", "Webhook Secret")}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="webhookSecret"
type={showSecret ? "text" : "password"}
readOnly
value={settings?.webhook_secret_masked || t("calcom.settings.webhook.secretNotConfigured", "Not configured")}
className="pr-10 font-mono text-xs"
/>
<Button
type="button"
variant="ghost"
action="toggle"
entity="webhook-secret-visibility"
onClick={() => setShowSecret(!showSecret)}
aria-label={secretVisibilityLabel}
className="absolute right-3 top-1/2 -translate-y-1/2 h-auto p-0 text-muted-foreground hover:bg-transparent hover:text-foreground">
{showSecret ?
<EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
</Button>
</div>
<Button
action="copy"
entity="webhook-secret"
variant="outline"
size="sm"
aria-label={t("calcom.settings.webhook.copySecret", "Copy webhook secret")}
onClick={() => copyToClipboard(settings?.webhook_secret_masked || "")}>
<Copy className="h-4 w-4" />
</Button>
<Button
action="rotate"
entity="webhook-secret"
variant="outline"
size="sm"
aria-label={t("calcom.settings.webhook.rotate", "Rotate webhook secret")}
onClick={handleRotate}
disabled={rotating}>
{rotating ?
<Loader2 className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"calcom.settings.webhook.secretHelp",
"Cal.com displays the secret only once. Regenerating invalidates the previous value immediately — update it in Cal.com after rotating.",
)}
</p>
</div>
<div className="text-sm">
{settings?.last_event_at ?
<div className="flex items-center gap-2 text-foreground">
<CheckCircle2 className="h-4 w-4 text-success" />
<span>
{t("calcom.settings.webhook.lastEventPrefix", "Last event")} {formatRelativeTime(settings.last_event_at)}
{settings.last_event_type && (
<>
{" — "}
<code className="font-mono text-xs">{settings.last_event_type}</code>
</>
)}
</span>
</div>
: <div className="flex items-center gap-2 text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span>{t("calcom.settings.webhook.waitingForEvent", "Status: Waiting for first event")}</span>
</div>
}
</div>
</CardContent>
</Card>
)}
{/* Documentation */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("calcom.settings.docs.title", "Documentation")}</CardTitle>
<CardDescription>{t("calcom.settings.docs.description", "Reference material for working with the Cal.com plugin.")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<a href="https://cal.com/docs/api-reference/v2" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline block">
{t("calcom.settings.docs.calcomApi", "Cal.com API documentation")}
</a>
{/* Follow-up: confirm /admin/docs/calcom-plugin route exists; fallback to the FEATURES/docs entry once published. */}
<a href="/admin/docs/calcom-plugin" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline block">
{t("calcom.settings.docs.pluginGuide", "BlockNinja plugin guide")}
</a>
</CardContent>
</Card>
</div>
);
}
export default CalcomSettings;