From 840aac12d9abab215f296c7ba22fa1b77b0b7478 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Fri, 21 Aug 2026 18:29:33 +0700 Subject: [PATCH] feat: add the config section for integrate --- src/components/ui/copy-button.tsx | 45 ++++ src/components/ui/secret-field.tsx | 39 ++++ .../integration/api/integration.api.ts | 79 +++++++ .../integration/api/integration.keys.ts | 4 + .../integration/api/integration.mock.ts | 145 +++++++++++++ .../integration/api/integration.types.ts | 51 +++++ .../integration/components/AccessKeyTable.tsx | 180 ++++++++++++++++ .../components/ConnectionDetailsCard.tsx | 164 +++++++++++++++ .../components/CreateAccessKeyDialog.tsx | 121 +++++++++++ .../components/NewKeyResultDialog.tsx | 89 ++++++++ .../components/RevokeAccessKeyDialog.tsx | 125 +++++++++++ .../components/RotateAccessKeyDialog.tsx | 134 ++++++++++++ .../integration/hooks/useCreateAccessKey.ts | 15 ++ .../integration/hooks/useIntegration.ts | 14 ++ .../integration/hooks/useRevealSecret.ts | 8 + .../integration/hooks/useRevokeAccessKey.ts | 14 ++ .../integration/hooks/useRotateAccessKey.ts | 16 ++ src/features/integration/lib/grace-options.ts | 8 + .../status/components/BucketStatsTable.tsx | 21 ++ src/pages/BucketBrowser.tsx | 10 +- src/pages/Dashboard.tsx | 10 + src/pages/Integration.tsx | 196 ++++++++++++++++++ src/router.tsx | 9 + 23 files changed, 1496 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/copy-button.tsx create mode 100644 src/components/ui/secret-field.tsx create mode 100644 src/features/integration/api/integration.api.ts create mode 100644 src/features/integration/api/integration.keys.ts create mode 100644 src/features/integration/api/integration.mock.ts create mode 100644 src/features/integration/api/integration.types.ts create mode 100644 src/features/integration/components/AccessKeyTable.tsx create mode 100644 src/features/integration/components/ConnectionDetailsCard.tsx create mode 100644 src/features/integration/components/CreateAccessKeyDialog.tsx create mode 100644 src/features/integration/components/NewKeyResultDialog.tsx create mode 100644 src/features/integration/components/RevokeAccessKeyDialog.tsx create mode 100644 src/features/integration/components/RotateAccessKeyDialog.tsx create mode 100644 src/features/integration/hooks/useCreateAccessKey.ts create mode 100644 src/features/integration/hooks/useIntegration.ts create mode 100644 src/features/integration/hooks/useRevealSecret.ts create mode 100644 src/features/integration/hooks/useRevokeAccessKey.ts create mode 100644 src/features/integration/hooks/useRotateAccessKey.ts create mode 100644 src/features/integration/lib/grace-options.ts create mode 100644 src/pages/Integration.tsx diff --git a/src/components/ui/copy-button.tsx b/src/components/ui/copy-button.tsx new file mode 100644 index 0000000..a29ac7b --- /dev/null +++ b/src/components/ui/copy-button.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react" +import { Check, Copy } from "lucide-react" +import { Button } from "./button" +import { cn } from "@/lib/utils" + +interface CopyButtonProps { + value: string + label?: string + className?: string +} + +/** + * Ghost button that copies `value` to the clipboard and flips to a check mark for 2 seconds. + */ +export function CopyButton({ value, label, className }: CopyButtonProps) { + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!copied) return + const timer = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(timer) + }, [copied]) + + const handleCopy = () => { + navigator.clipboard.writeText(value).then(() => setCopied(true)) + } + + return ( + + ) +} diff --git a/src/components/ui/secret-field.tsx b/src/components/ui/secret-field.tsx new file mode 100644 index 0000000..687562a --- /dev/null +++ b/src/components/ui/secret-field.tsx @@ -0,0 +1,39 @@ +import { Eye, EyeOff } from "lucide-react" +import { Button } from "./button" +import { CopyButton } from "./copy-button" +import { cn } from "@/lib/utils" + +interface SecretFieldProps { + value: string + onReveal: () => void + isRevealing: boolean + className?: string +} + +/** + * Masked secret value with an eye toggle (reveal can lazily fetch) and a copy button + * that only appears once the value is visible. + */ +export function SecretField({ value, onReveal, isRevealing, className }: SecretFieldProps) { + const revealed = value.length > 0 + + return ( +
+ + {revealed ? value : "••••••••••••••••••••"} + + {revealed && } + +
+ ) +} diff --git a/src/features/integration/api/integration.api.ts b/src/features/integration/api/integration.api.ts new file mode 100644 index 0000000..4214bd7 --- /dev/null +++ b/src/features/integration/api/integration.api.ts @@ -0,0 +1,79 @@ +import { apiRequest, IS_MOCK_MODE } from "@/lib/api-client" +import { + mockCreateAccessKey, + mockFetchIntegration, + mockRevealAccessKeySecret, + mockRevokeAccessKey, + mockRotateAccessKey, +} from "./integration.mock" +import type { + AccessKeyFull, + AccessKeySecretResponse, + CreateAccessKeyRequest, + GraceSeconds, + IntegrationInfo, + RotateAccessKeyResponse, +} from "./integration.types" + +/** + * Fetch connection details and access key metadata. + */ +export async function fetchIntegration(signal?: AbortSignal): Promise { + if (IS_MOCK_MODE) { + return mockFetchIntegration() + } + return apiRequest("/api/integration", { method: "GET", signal }) +} + +/** + * Create a named access key pair. The secret is only returned here. + */ +export async function createAccessKey(data: CreateAccessKeyRequest): Promise { + if (IS_MOCK_MODE) { + return mockCreateAccessKey(data) + } + return apiRequest("/api/integration/keys", { + method: "POST", + body: JSON.stringify(data), + }) +} + +/** + * Rotate a key. The old key is deleted immediately when graceSeconds is 0. + */ +export async function rotateAccessKey(accessKeyId: string, graceSeconds: GraceSeconds): Promise { + if (IS_MOCK_MODE) { + return mockRotateAccessKey(accessKeyId, graceSeconds) + } + return apiRequest( + `/api/integration/keys/${encodeURIComponent(accessKeyId)}/rotate`, + { + method: "POST", + body: JSON.stringify({ graceSeconds }), + }, + ) +} + +/** + * Reveal a key's secret (rate-limited server-side). + */ +export async function revealAccessKeySecret(accessKeyId: string): Promise { + if (IS_MOCK_MODE) { + return mockRevealAccessKeySecret(accessKeyId) + } + return apiRequest(`/api/integration/keys/${encodeURIComponent(accessKeyId)}/secret`, { + method: "GET", + }) +} + +/** + * Revoke a key immediately. + */ +export async function revokeAccessKey(accessKeyId: string): Promise { + if (IS_MOCK_MODE) { + return mockRevokeAccessKey(accessKeyId) + } + return apiRequest(`/api/integration/keys/${encodeURIComponent(accessKeyId)}`, { + method: "DELETE", + }) +} diff --git a/src/features/integration/api/integration.keys.ts b/src/features/integration/api/integration.keys.ts new file mode 100644 index 0000000..04260a8 --- /dev/null +++ b/src/features/integration/api/integration.keys.ts @@ -0,0 +1,4 @@ +export const integrationKeys = { + all: ["integration"] as const, + info: () => [...integrationKeys.all, "info"] as const, +} diff --git a/src/features/integration/api/integration.mock.ts b/src/features/integration/api/integration.mock.ts new file mode 100644 index 0000000..56851c5 --- /dev/null +++ b/src/features/integration/api/integration.mock.ts @@ -0,0 +1,145 @@ +import type { + AccessKeyFull, + AccessKeyMetadata, + AccessKeySecretResponse, + CreateAccessKeyRequest, + GraceSeconds, + IntegrationInfo, + RotateAccessKeyResponse, +} from "./integration.types" + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +function generateAccessKeyId(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + const bytes = new Uint8Array(17) + crypto.getRandomValues(bytes) + let id = "GDS" + for (const byte of bytes) id += chars[byte % chars.length] + return id +} + +function generateSecret(): string { + const bytes = new Uint8Array(30) + crypto.getRandomValues(bytes) + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +/** In-memory key store so the page is fully usable with no backend. */ +let mockKeys: AccessKeyFull[] = [ + { + accessKeyId: "GDSMOCKKEY00000001", + secretAccessKey: "mock-secret-access-key-do-not-use-0000000000", + label: "rclone-backup", + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6).toISOString(), + expiresAt: null, + }, + { + accessKeyId: "GDSMOCKKEY00000002", + secretAccessKey: "mock-secret-access-key-do-not-use-1111111111", + label: "n8n-media-uploads", + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 23).toISOString(), + }, +] + +const MOCK_BUCKETS = ["documents", "media-storage", "backups", "public-assets"] + +function toMetadata(key: AccessKeyFull): AccessKeyMetadata { + const { secretAccessKey: _secret, ...metadata } = key + return metadata +} + +function requireKey(accessKeyId: string): AccessKeyFull { + const key = mockKeys.find((k) => k.accessKeyId === accessKeyId) + if (!key) { + throw new Error("Access key not found") + } + return key +} + +export async function mockFetchIntegration(): Promise { + await delay(400) + return { + endpoint: window.location.origin, + region: "auto", + forcePathStyle: true, + buckets: MOCK_BUCKETS, + publicReadBuckets: ["public-assets"], + multipartEnabled: true, + etagStyle: "md5", + corsOrigins: ["http://localhost:5173"], + docsUrl: `${window.location.origin.replace(/:\d+$/, ":8787")}/docs`, + openApiUrl: `${window.location.origin.replace(/:\d+$/, ":8787")}/openapi.yaml`, + accessKeys: mockKeys.map(toMetadata), + limits: { + maxAccessKeys: 5, + keyPropagationSeconds: 60, + presignExpiryMaxSeconds: 7 * 24 * 60 * 60, + }, + } +} + +export async function mockCreateAccessKey(data: CreateAccessKeyRequest): Promise { + await delay(500) + const label = data.label.trim() + if (!label || label.length > 32 || !/^[a-zA-Z0-9 _-]+$/.test(label)) { + throw new Error("Label must be 1-32 characters using letters, numbers, spaces, '_' or '-'") + } + if (mockKeys.length >= 5) { + throw new Error("Maximum of 5 access keys reached. Revoke or rotate an existing key first.") + } + const key: AccessKeyFull = { + accessKeyId: generateAccessKeyId(), + secretAccessKey: generateSecret(), + label, + createdAt: new Date().toISOString(), + expiresAt: null, + } + mockKeys = [...mockKeys, key] + return key +} + +export async function mockRotateAccessKey(accessKeyId: string, graceSeconds: GraceSeconds): Promise { + await delay(500) + const previous = requireKey(accessKeyId) + if (![0, 3600, 86400, 604800].includes(graceSeconds)) { + throw new Error("graceSeconds must be one of 0, 3600, 86400, 604800") + } + if (mockKeys.length >= 5 && graceSeconds !== 0) { + throw new Error("Maximum of 5 access keys reached. Revoke an existing key first.") + } + const created: AccessKeyFull = { + accessKeyId: generateAccessKeyId(), + secretAccessKey: generateSecret(), + label: previous.label, + createdAt: new Date().toISOString(), + expiresAt: null, + } + mockKeys = mockKeys + .map((k) => + k.accessKeyId === accessKeyId + ? { ...k, expiresAt: graceSeconds === 0 ? null : new Date(Date.now() + graceSeconds * 1000).toISOString() } + : k, + ) + .filter((k) => k.accessKeyId !== accessKeyId || graceSeconds !== 0) + mockKeys = [...mockKeys, created] + return { + created, + previous: { + accessKeyId: previous.accessKeyId, + expiresAt: graceSeconds === 0 ? null : new Date(Date.now() + graceSeconds * 1000).toISOString(), + }, + } +} + +export async function mockRevealAccessKeySecret(accessKeyId: string): Promise { + await delay(300) + return { secretAccessKey: requireKey(accessKeyId).secretAccessKey } +} + +export async function mockRevokeAccessKey(accessKeyId: string): Promise { + await delay(400) + requireKey(accessKeyId) + mockKeys = mockKeys.filter((k) => k.accessKeyId !== accessKeyId) +} diff --git a/src/features/integration/api/integration.types.ts b/src/features/integration/api/integration.types.ts new file mode 100644 index 0000000..f9d377c --- /dev/null +++ b/src/features/integration/api/integration.types.ts @@ -0,0 +1,51 @@ +export interface AccessKeyMetadata { + accessKeyId: string + label: string + createdAt: string + expiresAt: string | null +} + +export interface AccessKeyFull extends AccessKeyMetadata { + secretAccessKey: string +} + +export interface IntegrationInfo { + endpoint: string + region: string + forcePathStyle: true + buckets: string[] + publicReadBuckets: string[] + multipartEnabled: boolean + etagStyle: "md5" | "multipart" + corsOrigins: string[] + docsUrl: string | null + openApiUrl: string | null + accessKeys: AccessKeyMetadata[] + limits: { + maxAccessKeys: number + keyPropagationSeconds: number + presignExpiryMaxSeconds: number + } +} + +export interface CreateAccessKeyRequest { + label: string +} + +export type GraceSeconds = 0 | 3600 | 86400 | 604800 + +export interface RotateAccessKeyRequest { + graceSeconds: GraceSeconds +} + +export interface RotateAccessKeyResponse { + created: AccessKeyFull + previous: { + accessKeyId: string + expiresAt: string | null + } +} + +export interface AccessKeySecretResponse { + secretAccessKey: string +} diff --git a/src/features/integration/components/AccessKeyTable.tsx b/src/features/integration/components/AccessKeyTable.tsx new file mode 100644 index 0000000..2f47ef3 --- /dev/null +++ b/src/features/integration/components/AccessKeyTable.tsx @@ -0,0 +1,180 @@ +import { useState } from "react" +import { KeyRound, Plus, RotateCw, Trash2, TimerReset } from "lucide-react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { SecretField } from "@/components/ui/secret-field" +import { formatRelativeTime } from "@/lib/format" +import { useRevealSecret } from "../hooks/useRevealSecret" +import type { AccessKeyMetadata } from "../api/integration.types" + +interface AccessKeyTableProps { + accessKeys?: AccessKeyMetadata[] + maxKeys: number + isLoading: boolean + onCreateKey: () => void + onRotateKey: (key: AccessKeyMetadata) => void + onRevokeKey: (key: AccessKeyMetadata) => void +} + +function keyStatus(key: AccessKeyMetadata): { variant: "success" | "warning"; label: string } { + if (!key.expiresAt) { + return { variant: "success", label: "Active" } + } + const remainingMs = new Date(key.expiresAt).getTime() - Date.now() + if (remainingMs <= 0) { + return { variant: "warning", label: "Expired" } + } + const hours = Math.floor(remainingMs / (1000 * 60 * 60)) + const days = Math.floor(hours / 24) + return { + variant: "warning", + label: days >= 1 ? `Expires in ${days}d ${hours % 24}h` : `Expires in ${hours}h`, + } +} + +export function AccessKeyTable({ accessKeys, maxKeys, isLoading, onCreateKey, onRotateKey, onRevokeKey }: AccessKeyTableProps) { + const [revealedSecrets, setRevealedSecrets] = useState>({}) + const revealMutation = useRevealSecret() + + const handleReveal = (accessKeyId: string) => { + if (revealedSecrets[accessKeyId]) return + revealMutation.mutate(accessKeyId, { + onSuccess: (data) => { + setRevealedSecrets((prev) => ({ ...prev, [accessKeyId]: data.secretAccessKey })) + }, + }) + } + + return ( + + +
+
+
+ +
+
+ S3 Access Keys + + One key per integration — rotate and revoke independently. + {accessKeys && ( + + {accessKeys.length}/{maxKeys} used + + )} + +
+
+ +
+
+ + {isLoading ? ( + <> + + + + ) : !accessKeys || accessKeys.length === 0 ? ( +
+
+ +
+
No access keys
+

+ S3 clients are locked out until a key exists. Create one to connect tools like rclone or backup scripts. +

+
+ ) : ( + accessKeys.map((key) => { + const status = keyStatus(key) + const isRevealing = revealMutation.isPending && revealMutation.variables === key.accessKeyId + const revealError = revealMutation.isError && revealMutation.variables === key.accessKeyId + + return ( +
+
+
+ {key.label} + + {status.variant === "success" && } + {status.label} + +
+
+ + +
+
+ +
+
+ Access Key ID +
+ {key.accessKeyId} +
+
+
+ Secret Access Key + handleReveal(key.accessKeyId)} + isRevealing={isRevealing} + /> + {revealError && ( +

{revealMutation.error?.message || "Failed to reveal secret"}

+ )} +
+
+ +
+ Created {formatRelativeTime(key.createdAt)} + {key.expiresAt && Retires {formatRelativeTime(key.expiresAt)}} +
+
+ ) + }) + )} + +

+ Note: revoke and rotate take up to 60 seconds to propagate globally due to edge caching. Secrets are stored + readable server-side — anyone with dashboard access can reveal them. +

+
+
+ ) +} diff --git a/src/features/integration/components/ConnectionDetailsCard.tsx b/src/features/integration/components/ConnectionDetailsCard.tsx new file mode 100644 index 0000000..c607be6 --- /dev/null +++ b/src/features/integration/components/ConnectionDetailsCard.tsx @@ -0,0 +1,164 @@ +import { ArrowUpRight, Cable, FileCode2, FolderTree, Globe, Lock, MapPin } from "lucide-react" +import { Link } from "react-router" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { CopyButton } from "@/components/ui/copy-button" +import { Badge } from "@/components/ui/badge" +import type { IntegrationInfo } from "../api/integration.types" + +interface ConnectionDetailsCardProps { + info?: IntegrationInfo + isLoading: boolean +} + +interface DetailRowProps { + icon: React.ReactNode + label: string + value: string + copyValue?: string + note?: string + trailing?: React.ReactNode +} + +function DetailRow({ icon, label, value, copyValue, note, trailing }: DetailRowProps) { + return ( +
+
+ + {icon} + {label} + + +
+

{value}

+ {(note || trailing) && ( +
+ {note &&

{note}

} + {trailing} +
+ )} +
+ ) +} + +export function ConnectionDetailsCard({ info, isLoading }: ConnectionDetailsCardProps) { + if (isLoading || !info) { + return ( + + + + + + + + + + + + ) + } + + return ( + + +
+
+ +
+ Connection Details +
+ + Point any S3-compatible client at these values. Path-style addressing is always required. + +
+ + } + label="S3 Endpoint" + value={info.endpoint} + note="Use with force/path-style addressing" + /> + } + label="Region" + value={info.region} + note="Fixed in Worker config — changing it requires a redeploy" + /> + } + label="Addressing Style" + value="Path-style (forcePathStyle=true)" + note="Virtual-hosted style is not supported" + /> + } + label="Drive Root Folder" + value={`/${info.buckets.length} bucket${info.buckets.length === 1 ? "" : "s"} available`} + copyValue={info.buckets.join(", ")} + trailing={ + + Manage buckets + + } + /> + +
+
+
+ + API Reference +
+
+ {info.docsUrl ? ( + + /docs + + ) : ( + + Docs disabled + + )} + {info.openApiUrl && ( + + /openapi.yaml + + )} +
+
+ +
+
+ + CORS Allowed Origins +
+ {info.corsOrigins.length > 0 ? ( +
+ {info.corsOrigins.map((origin) => ( + + {origin} + + ))} +
+ ) : ( +

No CORS origins — browser access is disabled (CLI-only)

+ )} +

Set via Worker config — changing it requires a redeploy

+
+
+
+
+ ) +} diff --git a/src/features/integration/components/CreateAccessKeyDialog.tsx b/src/features/integration/components/CreateAccessKeyDialog.tsx new file mode 100644 index 0000000..441344b --- /dev/null +++ b/src/features/integration/components/CreateAccessKeyDialog.tsx @@ -0,0 +1,121 @@ +import * as React from "react" +import { AlertCircle, KeyRound, X } from "lucide-react" +import { Dialog } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useCreateAccessKey } from "../hooks/useCreateAccessKey" +import type { AccessKeyFull } from "../api/integration.types" + +interface CreateAccessKeyDialogProps { + open: boolean + onClose: () => void + onCreated: (key: AccessKeyFull) => void +} + +export function CreateAccessKeyDialog({ open, onClose, onCreated }: CreateAccessKeyDialogProps) { + const [label, setLabel] = React.useState("") + const [errorMessage, setErrorMessage] = React.useState(null) + const { mutate: create, isPending } = useCreateAccessKey() + + React.useEffect(() => { + if (open) { + setLabel("") + setErrorMessage(null) + } + }, [open]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setErrorMessage(null) + const trimmed = label.trim() + if (!trimmed) return + + create( + { label: trimmed }, + { + onSuccess: (key) => { + onClose() + onCreated(key) + }, + onError: (err) => { + setErrorMessage(err.message || "Failed to create access key") + }, + }, + ) + } + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Create Access Key

+

Generate a named key pair for one integration

+
+
+ +
+ +
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + +
+ + setLabel(e.target.value)} + disabled={isPending} + maxLength={32} + className="bg-background/60 border-border/80 rounded-xl h-10" + autoFocus + /> +

+ 1-32 characters — letters, numbers, spaces, underscore or hyphen. Name it after the tool that will use it. +

+
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/features/integration/components/NewKeyResultDialog.tsx b/src/features/integration/components/NewKeyResultDialog.tsx new file mode 100644 index 0000000..8fffede --- /dev/null +++ b/src/features/integration/components/NewKeyResultDialog.tsx @@ -0,0 +1,89 @@ +import { AlertTriangle, CheckCircle2, KeyRound, X } from "lucide-react" +import { Dialog } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { CopyButton } from "@/components/ui/copy-button" +import type { AccessKeyFull } from "../api/integration.types" + +interface NewKeyResultDialogProps { + open: boolean + onClose: () => void + /** The fresh pair — or the new key plus the caller-provided secret for a rotation. */ + created?: AccessKeyFull + title: string +} + +/** + * Shows a freshly minted key pair with copy buttons. The secret cannot be shown again + * after this dialog closes (except via Reveal). + */ +export function NewKeyResultDialog({ open, onClose, created, title }: NewKeyResultDialogProps) { + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

{title}

+

Copy the secret now — store it in your tool's config

+
+
+ +
+ +
+ {created && ( + <> +
+
+ + + Access Key ID + + +
+ {created.accessKeyId} +
+ +
+
+ Secret Access Key + +
+ {created.secretAccessKey} +
+ +
+ + + Label {created.label} + {created.expiresAt && ( + <> + {" "}— the previous key stops working after its grace period (up to 60s of edge-cache lag applies) + + )} + +
+ + )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} diff --git a/src/features/integration/components/RevokeAccessKeyDialog.tsx b/src/features/integration/components/RevokeAccessKeyDialog.tsx new file mode 100644 index 0000000..4dd586c --- /dev/null +++ b/src/features/integration/components/RevokeAccessKeyDialog.tsx @@ -0,0 +1,125 @@ +import * as React from "react" +import { AlertCircle, AlertTriangle, Trash2, X } from "lucide-react" +import { Dialog } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { useRevokeAccessKey } from "../hooks/useRevokeAccessKey" +import type { AccessKeyMetadata } from "../api/integration.types" + +interface RevokeAccessKeyDialogProps { + open: boolean + onClose: () => void + accessKey?: AccessKeyMetadata | null + isLastKey: boolean +} + +export function RevokeAccessKeyDialog({ open, onClose, accessKey, isLastKey }: RevokeAccessKeyDialogProps) { + const [errorMessage, setErrorMessage] = React.useState(null) + const { mutate: revoke, isPending } = useRevokeAccessKey() + + React.useEffect(() => { + if (open) { + setErrorMessage(null) + } + }, [open]) + + const handleRevoke = () => { + if (!accessKey) return + setErrorMessage(null) + revoke(accessKey.accessKeyId, { + onSuccess: onClose, + onError: (err) => { + setErrorMessage(err.message || "Failed to revoke access key") + }, + }) + } + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Revoke Access Key

+

+ {accessKey ? `Permanently delete "${accessKey.label}"` : "Permanently delete key"} +

+
+
+ +
+ +
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + +
+

+ + This cannot be undone +

+

+ Every client using this key pair will receive 403 AccessDenied within + 60 seconds (edge-cache propagation). +

+
+ + {isLastKey && ( +
+

+ + This is your last key +

+

+ Deleting it locks out all S3 clients until a new key is created + from this page. +

+
+ )} + +

+ If you only want to change the secret, use Rotate instead — it keeps + clients working during a grace period. +

+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/features/integration/components/RotateAccessKeyDialog.tsx b/src/features/integration/components/RotateAccessKeyDialog.tsx new file mode 100644 index 0000000..3b0675d --- /dev/null +++ b/src/features/integration/components/RotateAccessKeyDialog.tsx @@ -0,0 +1,134 @@ +import * as React from "react" +import { AlertCircle, RotateCw, X } from "lucide-react" +import { Dialog } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { useRotateAccessKey } from "../hooks/useRotateAccessKey" +import { GRACE_OPTIONS } from "../lib/grace-options" +import type { AccessKeyMetadata, GraceSeconds, RotateAccessKeyResponse } from "../api/integration.types" + +interface RotateAccessKeyDialogProps { + open: boolean + onClose: () => void + accessKey?: AccessKeyMetadata | null + onRotated: (result: RotateAccessKeyResponse) => void +} + +export function RotateAccessKeyDialog({ open, onClose, accessKey, onRotated }: RotateAccessKeyDialogProps) { + const [graceSeconds, setGraceSeconds] = React.useState(86400) + const [errorMessage, setErrorMessage] = React.useState(null) + const { mutate: rotate, isPending } = useRotateAccessKey() + + React.useEffect(() => { + if (open) { + setGraceSeconds(86400) + setErrorMessage(null) + } + }, [open]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!accessKey) return + setErrorMessage(null) + + rotate( + { accessKeyId: accessKey.accessKeyId, graceSeconds }, + { + onSuccess: (result) => { + onClose() + onRotated(result) + }, + onError: (err) => { + setErrorMessage(err.message || "Failed to rotate access key") + }, + }, + ) + } + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

Rotate Access Key

+

+ {accessKey ? `Replace "${accessKey.label}" with a new secret` : "Replace key"} +

+
+
+ +
+ +
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + +
+ How long should the old key keep working? +
+ {GRACE_OPTIONS.map((option) => ( + + ))} +
+
+ +

+ The replacement carries the same label. Changes propagate within 60 seconds due to edge caching. +

+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/features/integration/hooks/useCreateAccessKey.ts b/src/features/integration/hooks/useCreateAccessKey.ts new file mode 100644 index 0000000..a91ba1a --- /dev/null +++ b/src/features/integration/hooks/useCreateAccessKey.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createAccessKey } from "../api/integration.api" +import { integrationKeys } from "../api/integration.keys" +import type { CreateAccessKeyRequest } from "../api/integration.types" + +export function useCreateAccessKey() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: CreateAccessKeyRequest) => createAccessKey(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: integrationKeys.all }) + }, + }) +} diff --git a/src/features/integration/hooks/useIntegration.ts b/src/features/integration/hooks/useIntegration.ts new file mode 100644 index 0000000..f2e8bfb --- /dev/null +++ b/src/features/integration/hooks/useIntegration.ts @@ -0,0 +1,14 @@ +import { queryOptions, useQuery } from "@tanstack/react-query" +import { fetchIntegration } from "../api/integration.api" +import { integrationKeys } from "../api/integration.keys" + +export const integrationQueryOptions = () => + queryOptions({ + queryKey: integrationKeys.info(), + queryFn: ({ signal }) => fetchIntegration(signal), + staleTime: 60_000, // 60s + }) + +export function useIntegration() { + return useQuery(integrationQueryOptions()) +} diff --git a/src/features/integration/hooks/useRevealSecret.ts b/src/features/integration/hooks/useRevealSecret.ts new file mode 100644 index 0000000..47cc3d7 --- /dev/null +++ b/src/features/integration/hooks/useRevealSecret.ts @@ -0,0 +1,8 @@ +import { useMutation } from "@tanstack/react-query" +import { revealAccessKeySecret } from "../api/integration.api" + +export function useRevealSecret() { + return useMutation({ + mutationFn: (accessKeyId: string) => revealAccessKeySecret(accessKeyId), + }) +} diff --git a/src/features/integration/hooks/useRevokeAccessKey.ts b/src/features/integration/hooks/useRevokeAccessKey.ts new file mode 100644 index 0000000..50fb0e4 --- /dev/null +++ b/src/features/integration/hooks/useRevokeAccessKey.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { revokeAccessKey } from "../api/integration.api" +import { integrationKeys } from "../api/integration.keys" + +export function useRevokeAccessKey() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (accessKeyId: string) => revokeAccessKey(accessKeyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: integrationKeys.all }) + }, + }) +} diff --git a/src/features/integration/hooks/useRotateAccessKey.ts b/src/features/integration/hooks/useRotateAccessKey.ts new file mode 100644 index 0000000..dbb4f02 --- /dev/null +++ b/src/features/integration/hooks/useRotateAccessKey.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { rotateAccessKey } from "../api/integration.api" +import { integrationKeys } from "../api/integration.keys" +import type { GraceSeconds } from "../api/integration.types" + +export function useRotateAccessKey() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ accessKeyId, graceSeconds }: { accessKeyId: string; graceSeconds: GraceSeconds }) => + rotateAccessKey(accessKeyId, graceSeconds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: integrationKeys.all }) + }, + }) +} diff --git a/src/features/integration/lib/grace-options.ts b/src/features/integration/lib/grace-options.ts new file mode 100644 index 0000000..3687bca --- /dev/null +++ b/src/features/integration/lib/grace-options.ts @@ -0,0 +1,8 @@ +import type { GraceSeconds } from "../api/integration.types" + +export const GRACE_OPTIONS: Array<{ value: GraceSeconds; label: string; hint: string }> = [ + { value: 0, label: "Revoke now", hint: "Old key stops working immediately" }, + { value: 3600, label: "1 hour", hint: "Short overlap for quick migrations" }, + { value: 86400, label: "24 hours", hint: "Recommended — a day to update clients" }, + { value: 604800, label: "7 days", hint: "Long overlap for slow rollouts" }, +] diff --git a/src/features/status/components/BucketStatsTable.tsx b/src/features/status/components/BucketStatsTable.tsx index 93a3c64..72a4fed 100644 --- a/src/features/status/components/BucketStatsTable.tsx +++ b/src/features/status/components/BucketStatsTable.tsx @@ -16,6 +16,7 @@ import { HardDrive, CheckCircle2, RefreshCw, + Cable, } from "lucide-react" import { Link } from "react-router" import { useQueryClient } from "@tanstack/react-query" @@ -531,6 +532,16 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr + + + + + + + + + Integration + + + + + + + + {/* Main Content */} +
+ {/* Page Banner */} +
+
+ +
+
+
+ + Session Authenticated +
+

+ Integration & Access Keys +

+

+ Everything Dokploy, n8n, rclone or backup scripts need to connect — endpoint details, ready-to-paste + environment variables and per-integration S3 keys. +

+
+ +
+ +
+
+
+ + {error && ( +
+ +
+
Failed to load integration info
+

{error.message || "Unknown error occurred"}

+ +
+
+ )} + + {/* Connection details */} + + + {/* Access key management */} + setIsCreateOpen(true)} + onRotateKey={(key) => setRotatingKey(key)} + onRevokeKey={(key) => setRevokingKey(key)} + /> +
+ + {/* Dialogs */} + setIsCreateOpen(false)} + onCreated={(key) => setNewKeyResult({ title: "Access Key Created", created: key })} + /> + setRotatingKey(null)} + accessKey={rotatingKey} + onRotated={handleRotated} + /> + setRevokingKey(null)} + accessKey={revokingKey} + isLastKey={accessKeys.length === 1 && revokingKey !== null} + /> + setNewKeyResult(null)} + created={newKeyResult?.created} + title={newKeyResult?.title ?? ""} + /> + + ) +} diff --git a/src/router.tsx b/src/router.tsx index 0383162..fdf885a 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router" import SignIn from "@/pages/SignIn" import Dashboard from "@/pages/Dashboard" import BucketBrowser from "@/pages/BucketBrowser" +import Integration from "@/pages/Integration" import { ProtectedRoute } from "@/components/ProtectedRoute" import { PublicOnlyRoute } from "@/components/PublicOnlyRoute" @@ -33,6 +34,14 @@ export function AppRouter() { } /> + + + + } + /> } />