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 (
+
+ {copied ? : }
+ {label !== undefined && {copied ? "Copied" : label} }
+
+ )
+}
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 && }
+
+ {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
+
+ )}
+
+
+
+
= maxKeys}
+ className="h-8 text-xs px-3 rounded-lg gap-1.5 font-medium shadow-sm shadow-blue-500/20"
+ title={accessKeys && accessKeys.length >= maxKeys ? `Maximum of ${maxKeys} keys reached` : "Create a new access key pair"}
+ >
+
+ New Key
+
+
+
+
+ {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}
+
+
+
+ onRotateKey(key)}
+ disabled={isRevealing}
+ className="h-7 px-2 text-[11px] text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10 rounded-lg gap-1"
+ title="Create a replacement key with the same label"
+ >
+
+ Rotate
+
+ onRevokeKey(key)}
+ className="h-7 px-2 text-[11px] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg gap-1"
+ title="Permanently revoke this key"
+ >
+
+ Revoke
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+ 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 (
+
+
+
+ )
+}
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 */}
+
+
+ Done
+
+
+
+
+ )
+}
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 && (
+
+ )}
+
+
+
+
+ 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 */}
+
+
+ Cancel
+
+
+ {isPending ? "Revoking..." : "Revoke Key"}
+
+
+
+
+ )
+}
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 (
+
+
+
+ )
+}
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
+
Admin Mode
+ navigate("/integration")}
+ className="inline-flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 rounded-lg bg-secondary/70 border border-border/60 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-border transition-colors cursor-pointer"
+ title="Connection details and S3 access keys"
+ >
+
+ Integration
+
(null)
+ const [revokingKey, setRevokingKey] = useState(null)
+ const [newKeyResult, setNewKeyResult] = useState<{ title: string; created: AccessKeyFull } | null>(null)
+
+ const handleSignOut = async () => {
+ await signOut()
+ navigate("/sign-in", { replace: true })
+ }
+
+ const handleRotated = (result: RotateAccessKeyResponse) => {
+ setNewKeyResult({ title: "Key Rotated", created: result.created })
+ }
+
+ const accessKeys = info?.accessKeys ?? []
+
+ return (
+
+ {/* Navigation Header */}
+
+
+ {/* 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.
+
+
+
+
+ refetch()}
+ disabled={isLoading}
+ className="h-7 text-xs px-2 rounded-lg gap-1"
+ title="Refresh connection details"
+ >
+
+ Refresh
+
+
+
+
+
+ {error && (
+
+
+
+
Failed to load integration info
+
{error.message || "Unknown error occurred"}
+
refetch()} className="h-7 text-xs mt-1">
+ Retry
+
+
+
+ )}
+
+ {/* 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() {
}
/>
+
+
+
+ }
+ />
} />