summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/settings/_components/api-settings.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/app/reader/settings/_components/api-settings.tsx')
-rw-r--r--apps/web/app/reader/settings/_components/api-settings.tsx529
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>
+ )
+}