diff options
Diffstat (limited to 'apps/web/app/reader/settings/_components/api-settings.tsx')
| -rw-r--r-- | apps/web/app/reader/settings/_components/api-settings.tsx | 529 |
1 files changed, 529 insertions, 0 deletions
diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx new file mode 100644 index 0000000..cb69958 --- /dev/null +++ b/apps/web/app/reader/settings/_components/api-settings.tsx @@ -0,0 +1,529 @@ +"use client" + +import { useState, useEffect } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { notify } from "@/lib/notify" + +interface ApiKey { + keyIdentifier: string + keyPrefix: string + label: string | null + createdAt: string + lastUsedAt: string | null +} + +interface WebhookConfiguration { + webhookUrl: string | null + webhookSecret: string | null + webhookEnabled: boolean + consecutiveFailures: number +} + +function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const response = await fetch("/api/v1/keys") + if (!response.ok) throw new Error("Failed to load API keys") + const data = await response.json() + return data.keys as ApiKey[] + }, + }) +} + +function useCreateApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (label: string | null) => { + const response = await fetch("/api/v1/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create API key") + } + return response.json() as Promise<{ + fullKey: string + keyPrefix: string + keyIdentifier: string + }> + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useRevokeApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (keyIdentifier: string) => { + const response = await fetch(`/api/v1/keys/${keyIdentifier}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to revoke API key") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useWebhookConfig() { + return useQuery({ + queryKey: ["webhookConfig"], + queryFn: async () => { + const response = await fetch("/api/webhook-config") + if (!response.ok) throw new Error("Failed to load webhook config") + return response.json() as Promise<WebhookConfiguration> + }, + }) +} + +function useUpdateWebhookConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ( + updates: Partial<{ + webhookUrl: string + webhookSecret: string + webhookEnabled: boolean + }> + ) => { + const response = await fetch("/api/webhook-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to update webhook config") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }) + }, + }) +} + +function useTestWebhook() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/webhook-config/test", { + method: "POST", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to send test webhook") + } + return response.json() as Promise<{ + delivered: boolean + statusCode?: number + error?: string + }> + }, + }) +} + +function ApiKeysSection() { + const { data: apiKeys, isLoading } = useApiKeys() + const createApiKey = useCreateApiKey() + const revokeApiKey = useRevokeApiKey() + const [newKeyLabel, setNewKeyLabel] = useState("") + const [revealedKey, setRevealedKey] = useState<string | null>(null) + const [confirmRevokeIdentifier, setConfirmRevokeIdentifier] = useState< + string | null + >(null) + + function handleCreateKey() { + createApiKey.mutate(newKeyLabel.trim() || null, { + onSuccess: (data) => { + setRevealedKey(data.fullKey) + setNewKeyLabel("") + notify("API key created") + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleCopyKey() { + if (revealedKey) { + navigator.clipboard.writeText(revealedKey) + notify("API key copied to clipboard") + } + } + + function handleRevokeKey(keyIdentifier: string) { + revokeApiKey.mutate(keyIdentifier, { + onSuccess: () => { + notify("API key revoked") + setConfirmRevokeIdentifier(null) + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">API keys</h3> + <p className="mb-3 text-text-dim"> + use API keys to authenticate requests to the REST API + </p> + + {revealedKey && ( + <div className="mb-4 border border-status-warning p-3"> + <p className="mb-2 text-text-secondary"> + copy this key now — it will not be shown again + </p> + <div className="flex items-center gap-2"> + <code className="min-w-0 flex-1 overflow-x-auto bg-background-tertiary px-2 py-1 text-text-primary"> + {revealedKey} + </code> + <button + onClick={handleCopyKey} + className="shrink-0 border border-border px-3 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy + </button> + </div> + <button + onClick={() => setRevealedKey(null)} + className="mt-2 text-text-dim transition-colors hover:text-text-secondary" + > + dismiss + </button> + </div> + )} + + {isLoading ? ( + <p className="text-text-dim">loading keys ...</p> + ) : ( + <> + {apiKeys && apiKeys.length > 0 && ( + <div className="mb-4 border border-border"> + {apiKeys.map((apiKey) => ( + <div + key={apiKey.keyIdentifier} + className="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <code className="text-text-primary"> + {apiKey.keyPrefix}... + </code> + {apiKey.label && ( + <span className="text-text-dim">{apiKey.label}</span> + )} + </div> + <div className="text-text-dim"> + created{" "} + {new Date(apiKey.createdAt).toLocaleDateString()} + {apiKey.lastUsedAt && + ` · last used ${new Date(apiKey.lastUsedAt).toLocaleDateString()}`} + </div> + </div> + {confirmRevokeIdentifier === apiKey.keyIdentifier ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + onClick={() => + handleRevokeKey(apiKey.keyIdentifier) + } + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setConfirmRevokeIdentifier(null)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => + setConfirmRevokeIdentifier(apiKey.keyIdentifier) + } + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + ))} + </div> + )} + + {(!apiKeys || apiKeys.length < 5) && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={newKeyLabel} + onChange={(event) => setNewKeyLabel(event.target.value)} + placeholder="key label (optional)" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleCreateKey() + }} + /> + <button + onClick={handleCreateKey} + disabled={createApiKey.isPending} + className="shrink-0 border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {createApiKey.isPending ? "creating ..." : "create key"} + </button> + </div> + )} + </> + )} + </div> + ) +} + +function WebhookSection() { + const { data: webhookConfig, isLoading } = useWebhookConfig() + const updateWebhookConfig = useUpdateWebhookConfig() + const testWebhook = useTestWebhook() + const [webhookUrl, setWebhookUrl] = useState("") + const [webhookSecret, setWebhookSecret] = useState("") + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + useEffect(() => { + if (webhookConfig) { + setWebhookUrl(webhookConfig.webhookUrl ?? "") + setWebhookSecret(webhookConfig.webhookSecret ?? "") + } + }, [webhookConfig]) + + function handleSaveWebhookConfig() { + updateWebhookConfig.mutate( + { + webhookUrl: webhookUrl.trim(), + webhookSecret: webhookSecret.trim(), + }, + { + onSuccess: () => { + notify("webhook configuration saved") + setHasUnsavedChanges(false) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleToggleEnabled() { + if (!webhookConfig) return + + updateWebhookConfig.mutate( + { webhookEnabled: !webhookConfig.webhookEnabled }, + { + onSuccess: () => { + notify( + webhookConfig.webhookEnabled + ? "webhooks disabled" + : "webhooks enabled" + ) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleTestWebhook() { + testWebhook.mutate(undefined, { + onSuccess: (data) => { + if (data.delivered) { + notify(`test webhook delivered (status ${data.statusCode})`) + } else { + notify(`test webhook failed: ${data.error}`) + } + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleGenerateSecret() { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + const generatedSecret = Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + setWebhookSecret(generatedSecret) + setHasUnsavedChanges(true) + } + + if (isLoading) { + return <p className="text-text-dim">loading webhook configuration ...</p> + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">webhooks</h3> + <p className="mb-3 text-text-dim"> + receive HTTP POST notifications when new entries arrive in your + subscribed feeds + </p> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary">webhook URL</label> + <input + type="url" + value={webhookUrl} + onChange={(event) => { + setWebhookUrl(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="https://example.com/webhook" + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary"> + signing secret + </label> + <div className="flex items-center gap-2"> + <input + type="text" + value={webhookSecret} + onChange={(event) => { + setWebhookSecret(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="optional HMAC-SHA256 signing secret" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleGenerateSecret} + className="shrink-0 border border-border px-3 py-1.5 text-text-secondary transition-colors hover:text-text-primary" + > + generate + </button> + </div> + </div> + + <div className="mb-4 flex items-center gap-4"> + <button + onClick={handleSaveWebhookConfig} + disabled={!hasUnsavedChanges || updateWebhookConfig.isPending} + className="border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {updateWebhookConfig.isPending ? "saving ..." : "save"} + </button> + <button + onClick={handleToggleEnabled} + disabled={updateWebhookConfig.isPending} + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {webhookConfig?.webhookEnabled ? "disable" : "enable"} + </button> + <button + onClick={handleTestWebhook} + disabled={ + testWebhook.isPending || + !webhookConfig?.webhookEnabled || + !webhookConfig?.webhookUrl + } + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {testWebhook.isPending ? "sending ..." : "send test"} + </button> + </div> + + {webhookConfig && ( + <div className="flex items-center gap-3 text-text-dim"> + <span> + status:{" "} + <span + className={ + webhookConfig.webhookEnabled + ? "text-text-primary" + : "text-text-dim" + } + > + {webhookConfig.webhookEnabled ? "enabled" : "disabled"} + </span> + </span> + {webhookConfig.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {webhookConfig.consecutiveFailures} consecutive failure + {webhookConfig.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + )} + </div> + ) +} + +export function ApiSettings() { + const { data: userProfile, isLoading } = useUserProfile() + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading API settings ...</p> + } + + if (!userProfile) { + return ( + <p className="px-4 py-6 text-text-dim">failed to load API settings</p> + ) + } + + if (userProfile.tier !== "developer") { + return ( + <div className="px-4 py-3"> + <h3 className="mb-2 text-text-primary">developer API</h3> + <p className="mb-3 text-text-dim"> + the developer plan includes a read-only REST API and webhook push + notifications. upgrade to developer to access these features. + </p> + </div> + ) + } + + return ( + <div className="px-4 py-3"> + <ApiKeysSection /> + <WebhookSection /> + + <div> + <h3 className="mb-2 text-text-primary">API documentation</h3> + <p className="mb-3 text-text-dim"> + authenticate requests with an API key in the Authorization header: + </p> + <code className="block bg-background-tertiary px-3 py-2 text-text-secondary"> + Authorization: Bearer asn_your_key_here + </code> + <div className="mt-3 space-y-1 text-text-dim"> + <p>GET /api/v1/profile — your account info and limits</p> + <p>GET /api/v1/feeds — your subscribed feeds</p> + <p>GET /api/v1/folders — your folders</p> + <p> + GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier, + ?readStatus, ?savedStatus filters + </p> + <p>GET /api/v1/entries/:id — single entry with full content</p> + </div> + </div> + </div> + ) +} |