feat: add the config section for integrate

This commit is contained in:
2026-08-21 18:29:33 +07:00
parent a60de5cb5c
commit 840aac12d9
23 changed files with 1496 additions and 1 deletions
+45
View File
@@ -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 (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
className={cn(
"h-7 gap-1.5 px-2 text-muted-foreground hover:text-foreground",
copied && "text-emerald-500 hover:text-emerald-500",
className,
)}
title={copied ? "Copied" : "Copy"}
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
{label !== undefined && <span className="text-xs">{copied ? "Copied" : label}</span>}
</Button>
)
}
+39
View File
@@ -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 (
<div className={cn("flex items-center gap-1 min-w-0", className)}>
<span className="font-mono text-xs truncate flex-1" title={revealed ? value : undefined}>
{revealed ? value : "••••••••••••••••••••"}
</span>
{revealed && <CopyButton value={value} />}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onReveal}
disabled={isRevealing || revealed}
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
title={revealed ? "Secret revealed" : isRevealing ? "Revealing..." : "Reveal secret"}
>
{revealed ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
)
}
@@ -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<IntegrationInfo> {
if (IS_MOCK_MODE) {
return mockFetchIntegration()
}
return apiRequest<IntegrationInfo>("/api/integration", { method: "GET", signal })
}
/**
* Create a named access key pair. The secret is only returned here.
*/
export async function createAccessKey(data: CreateAccessKeyRequest): Promise<AccessKeyFull> {
if (IS_MOCK_MODE) {
return mockCreateAccessKey(data)
}
return apiRequest<AccessKeyFull>("/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<RotateAccessKeyResponse> {
if (IS_MOCK_MODE) {
return mockRotateAccessKey(accessKeyId, graceSeconds)
}
return apiRequest<RotateAccessKeyResponse>(
`/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<AccessKeySecretResponse> {
if (IS_MOCK_MODE) {
return mockRevealAccessKeySecret(accessKeyId)
}
return apiRequest<AccessKeySecretResponse>(`/api/integration/keys/${encodeURIComponent(accessKeyId)}/secret`, {
method: "GET",
})
}
/**
* Revoke a key immediately.
*/
export async function revokeAccessKey(accessKeyId: string): Promise<void> {
if (IS_MOCK_MODE) {
return mockRevokeAccessKey(accessKeyId)
}
return apiRequest<void>(`/api/integration/keys/${encodeURIComponent(accessKeyId)}`, {
method: "DELETE",
})
}
@@ -0,0 +1,4 @@
export const integrationKeys = {
all: ["integration"] as const,
info: () => [...integrationKeys.all, "info"] as const,
}
@@ -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<IntegrationInfo> {
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<AccessKeyFull> {
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<RotateAccessKeyResponse> {
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<AccessKeySecretResponse> {
await delay(300)
return { secretAccessKey: requireKey(accessKeyId).secretAccessKey }
}
export async function mockRevokeAccessKey(accessKeyId: string): Promise<void> {
await delay(400)
requireKey(accessKeyId)
mockKeys = mockKeys.filter((k) => k.accessKeyId !== accessKeyId)
}
@@ -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
}
@@ -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<Record<string, string>>({})
const revealMutation = useRevealSecret()
const handleReveal = (accessKeyId: string) => {
if (revealedSecrets[accessKeyId]) return
revealMutation.mutate(accessKeyId, {
onSuccess: (data) => {
setRevealedSecrets((prev) => ({ ...prev, [accessKeyId]: data.secretAccessKey }))
},
})
}
return (
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md">
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400">
<KeyRound className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base font-bold">S3 Access Keys</CardTitle>
<CardDescription className="text-xs">
One key per integration rotate and revoke independently.
{accessKeys && (
<span className="ml-1 font-semibold">
{accessKeys.length}/{maxKeys} used
</span>
)}
</CardDescription>
</div>
</div>
<Button
size="sm"
onClick={onCreateKey}
disabled={isLoading || (accessKeys?.length ?? 0) >= 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"}
>
<Plus className="h-3.5 w-3.5" />
New Key
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2.5">
{isLoading ? (
<>
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</>
) : !accessKeys || accessKeys.length === 0 ? (
<div className="py-8 px-4 rounded-xl border border-dashed border-border/80 bg-muted/15 flex flex-col items-center justify-center text-center gap-2">
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400">
<KeyRound className="h-6 w-6" />
</div>
<div className="text-sm font-bold text-foreground">No access keys</div>
<p className="text-xs text-muted-foreground max-w-sm">
S3 clients are locked out until a key exists. Create one to connect tools like rclone or backup scripts.
</p>
</div>
) : (
accessKeys.map((key) => {
const status = keyStatus(key)
const isRevealing = revealMutation.isPending && revealMutation.variables === key.accessKeyId
const revealError = revealMutation.isError && revealMutation.variables === key.accessKeyId
return (
<div
key={key.accessKeyId}
className="p-3.5 rounded-xl bg-secondary/40 border border-border/60 space-y-2.5 hover:border-border transition-colors"
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-bold text-foreground truncate">{key.label}</span>
<Badge variant={status.variant} className="text-[10px] px-1.5 py-0 shrink-0">
{status.variant === "success" && <TimerReset className="hidden" />}
{status.label}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => 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"
>
<RotateCw className="h-3 w-3" />
Rotate
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => 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"
>
<Trash2 className="h-3 w-3" />
Revoke
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
<div className="space-y-0.5 min-w-0">
<span className="text-[10px] uppercase font-semibold text-muted-foreground tracking-wider">Access Key ID</span>
<div className="flex items-center gap-1 min-w-0">
<code className="font-mono text-xs text-foreground truncate">{key.accessKeyId}</code>
</div>
</div>
<div className="space-y-0.5 min-w-0">
<span className="text-[10px] uppercase font-semibold text-muted-foreground tracking-wider">Secret Access Key</span>
<SecretField
value={revealedSecrets[key.accessKeyId] ?? ""}
onReveal={() => handleReveal(key.accessKeyId)}
isRevealing={isRevealing}
/>
{revealError && (
<p className="text-[11px] text-destructive">{revealMutation.error?.message || "Failed to reveal secret"}</p>
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 pt-1 border-t border-border/50 text-[11px] text-muted-foreground flex-wrap">
<span>Created {formatRelativeTime(key.createdAt)}</span>
{key.expiresAt && <span className="font-medium">Retires {formatRelativeTime(key.expiresAt)}</span>}
</div>
</div>
)
})
)}
<p className="text-[11px] text-muted-foreground pt-1">
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.
</p>
</CardContent>
</Card>
)
}
@@ -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 (
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 min-w-0">
{icon}
<span>{label}</span>
</span>
<CopyButton value={copyValue ?? value} />
</div>
<p className="font-mono text-xs text-foreground/90 break-all">{value}</p>
{(note || trailing) && (
<div className="flex items-center justify-between gap-2 flex-wrap">
{note && <p className="text-[11px] text-muted-foreground">{note}</p>}
{trailing}
</div>
)}
</div>
)
}
export function ConnectionDetailsCard({ info, isLoading }: ConnectionDetailsCardProps) {
if (isLoading || !info) {
return (
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md">
<CardHeader>
<Skeleton className="h-5 w-44" />
<Skeleton className="h-3 w-64 mt-1" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</CardContent>
</Card>
)
}
return (
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md">
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400">
<Cable className="h-4 w-4" />
</div>
<CardTitle className="text-base font-bold">Connection Details</CardTitle>
</div>
<CardDescription className="text-xs">
Point any S3-compatible client at these values. Path-style addressing is always required.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow
icon={<Globe className="h-3.5 w-3.5 text-blue-400" />}
label="S3 Endpoint"
value={info.endpoint}
note="Use with force/path-style addressing"
/>
<DetailRow
icon={<MapPin className="h-3.5 w-3.5 text-indigo-400" />}
label="Region"
value={info.region}
note="Fixed in Worker config — changing it requires a redeploy"
/>
<DetailRow
icon={<Lock className="h-3.5 w-3.5 text-zinc-400" />}
label="Addressing Style"
value="Path-style (forcePathStyle=true)"
note="Virtual-hosted style is not supported"
/>
<DetailRow
icon={<FolderTree className="h-3.5 w-3.5 text-emerald-500" />}
label="Drive Root Folder"
value={`/${info.buckets.length} bucket${info.buckets.length === 1 ? "" : "s"} available`}
copyValue={info.buckets.join(", ")}
trailing={
<Link
to="/"
className="text-[11px] font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
Manage buckets
</Link>
}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<FileCode2 className="h-3.5 w-3.5 text-purple-400" />
<span>API Reference</span>
</div>
<div className="flex items-center gap-3 pt-0.5">
{info.docsUrl ? (
<a
href={info.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
/docs <ArrowUpRight className="h-3 w-3" />
</a>
) : (
<Badge variant="neutral" className="text-[10px] px-1.5 py-0">
Docs disabled
</Badge>
)}
{info.openApiUrl && (
<a
href={info.openApiUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
/openapi.yaml <ArrowUpRight className="h-3 w-3" />
</a>
)}
</div>
</div>
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-cyan-500" />
<span>CORS Allowed Origins</span>
</div>
{info.corsOrigins.length > 0 ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{info.corsOrigins.map((origin) => (
<code key={origin} className="px-1.5 py-0.5 rounded-md bg-background/70 border border-border/60 text-[10px] font-mono text-foreground/80">
{origin}
</code>
))}
</div>
) : (
<p className="text-[11px] text-muted-foreground">No CORS origins browser access is disabled (CLI-only)</p>
)}
<p className="text-[11px] text-muted-foreground">Set via Worker config changing it requires a redeploy</p>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -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<string | null>(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 (
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-amber-500/5 via-orange-500/5 to-transparent">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 shadow-xs">
<KeyRound className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">Create Access Key</h2>
<p className="text-xs text-muted-foreground mt-0.5">Generate a named key pair for one integration</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-6 space-y-5">
{errorMessage && (
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="key-label" className="text-foreground">Label</Label>
<Input
id="key-label"
placeholder="rclone-backup"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={isPending}
maxLength={32}
className="bg-background/60 border-border/80 rounded-xl h-10"
autoFocus
/>
<p className="text-[11px] text-muted-foreground">
1-32 characters letters, numbers, spaces, underscore or hyphen. Name it after the tool that will use it.
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
disabled={isPending}
className="h-9 px-3.5 font-medium border-border/80 bg-background/70 hover:bg-muted/80 shadow-xs"
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={isPending || !label.trim()}
className="h-9 px-4 font-medium shadow-sm shadow-blue-500/25"
>
{isPending ? "Creating..." : "Create Key"}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -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 (
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-emerald-500/5 via-teal-500/5 to-transparent">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 shadow-xs">
<CheckCircle2 className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground mt-0.5">Copy the secret now store it in your tool's config</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-6 space-y-4">
{created && (
<>
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-amber-500" />
Access Key ID
</span>
<CopyButton value={created.accessKeyId} />
</div>
<code className="font-mono text-xs text-foreground/90 break-all">{created.accessKeyId}</code>
</div>
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>Secret Access Key</span>
<CopyButton value={created.secretAccessKey} />
</div>
<code className="font-mono text-xs text-foreground/90 break-all">{created.secretAccessKey}</code>
</div>
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-start gap-2.5 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
Label <span className="font-semibold">{created.label}</span>
{created.expiresAt && (
<>
{" "} the previous key stops working after its grace period (up to 60s of edge-cache lag applies)
</>
)}
</span>
</div>
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
<Button type="button" size="sm" onClick={onClose} className="h-9 px-4 font-medium shadow-sm shadow-blue-500/25">
Done
</Button>
</div>
</div>
</Dialog>
)
}
@@ -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<string | null>(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 (
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-rose-500/5 via-red-500/5 to-transparent">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 shadow-xs">
<Trash2 className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">Revoke Access Key</h2>
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">
{accessKey ? `Permanently delete "${accessKey.label}"` : "Permanently delete key"}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-6 space-y-4">
{errorMessage && (
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 space-y-1.5">
<p className="text-xs font-semibold text-rose-600 dark:text-rose-400 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" />
This cannot be undone
</p>
<p className="text-[11px] text-rose-600/90 dark:text-rose-400/90">
Every client using this key pair will receive <code className="font-mono">403 AccessDenied</code> within
60 seconds (edge-cache propagation).
</p>
</div>
{isLastKey && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 space-y-1.5">
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" />
This is your last key
</p>
<p className="text-[11px] text-amber-600/90 dark:text-amber-400/90">
Deleting it locks out <span className="font-semibold">all</span> S3 clients until a new key is created
from this page.
</p>
</div>
)}
<p className="text-[11px] text-muted-foreground">
If you only want to change the secret, use <span className="font-semibold">Rotate</span> instead it keeps
clients working during a grace period.
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
disabled={isPending}
className="h-9 px-3.5 font-medium border-border/80 bg-background/70 hover:bg-muted/80 shadow-xs"
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRevoke}
disabled={isPending}
className="h-9 px-4 font-medium"
>
{isPending ? "Revoking..." : "Revoke Key"}
</Button>
</div>
</div>
</Dialog>
)
}
@@ -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<GraceSeconds>(86400)
const [errorMessage, setErrorMessage] = React.useState<string | null>(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 (
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-blue-500/5 via-indigo-500/5 to-transparent">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400 shadow-xs">
<RotateCw className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">Rotate Access Key</h2>
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">
{accessKey ? `Replace "${accessKey.label}" with a new secret` : "Replace key"}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-6 space-y-5">
{errorMessage && (
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-foreground">How long should the old key keep working?</span>
<div className="space-y-2">
{GRACE_OPTIONS.map((option) => (
<label
key={option.value}
className={`flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-colors ${
graceSeconds === option.value
? "border-blue-500/40 bg-blue-500/10"
: "border-border/70 bg-background/50 hover:bg-muted/60"
}`}
>
<input
type="radio"
name="grace-period"
value={option.value}
checked={graceSeconds === option.value}
onChange={() => setGraceSeconds(option.value)}
disabled={isPending}
className="mt-0.5 accent-blue-600"
/>
<span className="min-w-0">
<span className="block text-xs font-semibold text-foreground">{option.label}</span>
<span className="block text-[11px] text-muted-foreground">{option.hint}</span>
</span>
</label>
))}
</div>
</div>
<p className="text-[11px] text-muted-foreground">
The replacement carries the same label. Changes propagate within 60 seconds due to edge caching.
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
disabled={isPending}
className="h-9 px-3.5 font-medium border-border/80 bg-background/70 hover:bg-muted/80 shadow-xs"
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={isPending} className="h-9 px-4 font-medium shadow-sm shadow-blue-500/25">
{isPending ? "Rotating..." : "Rotate Key"}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -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 })
},
})
}
@@ -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())
}
@@ -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),
})
}
@@ -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 })
},
})
}
@@ -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 })
},
})
}
@@ -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" },
]
@@ -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
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</Link>
<Link to={`/integration?bucket=${encodeURIComponent(b.name)}`}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10 rounded-lg transition-colors"
title="Connect a tool to this bucket"
>
<Cable className="h-3.5 w-3.5" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
@@ -586,6 +597,16 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</Link>
<Link to={`/integration?bucket=${encodeURIComponent(b.name)}`}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10 rounded-lg"
title="Connect a tool to this bucket"
>
<Cable className="h-3.5 w-3.5" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
+9 -1
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, Database, HardDrive, Shield } from "lucide-react"
import { ArrowLeft, Cable, Database, HardDrive, Shield } from "lucide-react"
import { Link, useParams, useSearchParams } from "react-router"
import { Badge } from "../components/ui/badge"
import { Button } from "../components/ui/button"
@@ -59,6 +59,14 @@ export function BucketBrowser() {
</div>
<div className="flex items-center gap-2">
<Link
to="/integration"
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 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"
title="Connection details and S3 access keys"
>
<Cable className="h-3.5 w-3.5 text-blue-500" />
Integration
</Link>
<ThemeToggle />
<Button
type="button"
+10
View File
@@ -8,6 +8,7 @@ import {
RotateCw,
Radio,
Lock,
Cable,
} from "lucide-react"
import { useAuth } from "@/features/auth/hooks/useAuth"
import { useStatus } from "@/features/status/hooks/useStatus"
@@ -98,6 +99,15 @@ export default function Dashboard() {
<Lock className="h-3.5 w-3.5 text-emerald-500" />
<span className="font-medium text-foreground/80">Admin Mode</span>
</div>
<button
type="button"
onClick={() => 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"
>
<Cable className="h-3.5 w-3.5 text-blue-500" />
<span className="hidden min-[480px]:inline">Integration</span>
</button>
<ThemeToggle showLabel={false} />
<Button
variant="outline"
+196
View File
@@ -0,0 +1,196 @@
import { useState } from "react"
import { useNavigate } from "react-router"
import {
AlertCircle,
Cable,
Database,
LogOut,
RotateCw,
ShieldCheck,
} from "lucide-react"
import { useAuth } from "@/features/auth/hooks/useAuth"
import { Button } from "@/components/ui/button"
import { ThemeToggle } from "@/components/ThemeToggle"
import { useIntegration } from "@/features/integration/hooks/useIntegration"
import { ConnectionDetailsCard } from "@/features/integration/components/ConnectionDetailsCard"
import { AccessKeyTable } from "@/features/integration/components/AccessKeyTable"
import { CreateAccessKeyDialog } from "@/features/integration/components/CreateAccessKeyDialog"
import { NewKeyResultDialog } from "@/features/integration/components/NewKeyResultDialog"
import { RotateAccessKeyDialog } from "@/features/integration/components/RotateAccessKeyDialog"
import { RevokeAccessKeyDialog } from "@/features/integration/components/RevokeAccessKeyDialog"
import type { AccessKeyFull, AccessKeyMetadata, RotateAccessKeyResponse } from "@/features/integration/api/integration.types"
export default function Integration() {
const { signOut } = useAuth()
const navigate = useNavigate()
const { data: info, isLoading, error, refetch } = useIntegration()
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [rotatingKey, setRotatingKey] = useState<AccessKeyMetadata | null>(null)
const [revokingKey, setRevokingKey] = useState<AccessKeyMetadata | null>(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 (
<div className="relative min-h-screen bg-background bg-grid-pattern flex flex-col overflow-x-hidden">
{/* Navigation Header */}
<header className="border-b border-border/70 bg-card/80 backdrop-blur-md sticky top-0 z-20 shadow-xs">
<div className="max-w-6xl mx-auto px-3 sm:px-6 min-h-16 py-2.5 sm:py-0 flex items-center justify-between gap-3">
{/* Brand Info */}
<div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="relative shrink-0">
<div className="h-9 w-9 sm:h-10 sm:w-10 rounded-xl bg-gradient-to-tr from-blue-600 via-indigo-600 to-cyan-500 text-white flex items-center justify-center shadow-md shadow-blue-500/20">
<Database className="h-4.5 w-4.5 sm:h-5 sm:w-5 stroke-[2.2]" />
</div>
<span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5 sm:h-3 sm:w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-full w-full bg-emerald-500 ring-2 ring-card" />
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-sm sm:text-base font-bold tracking-tight leading-tight text-foreground truncate">
S3 Drive <span className="bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">Storage</span>
</h1>
<span className="hidden min-[480px]:inline-flex px-2 py-0.5 rounded-full text-[10px] font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20 shrink-0">
v1.0 Gateway
</span>
</div>
<p className="text-[11px] sm:text-xs text-muted-foreground mt-0.5 truncate max-w-[220px] sm:max-w-none">
<span className="hidden sm:inline">Connect external tools to your S3-compatible gateway</span>
<span className="sm:hidden">Integration Console</span>
</p>
</div>
</div>
{/* Actions & Badges */}
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/")}
className="text-xs font-medium h-8 px-2.5 sm:px-3 rounded-lg text-muted-foreground hover:text-foreground"
>
Dashboard
</Button>
<span className="hidden min-[420px]:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-blue-500/10 border border-blue-500/20 text-xs font-semibold text-blue-600 dark:text-blue-400">
<Cable className="h-3.5 w-3.5" />
Integration
</span>
<ThemeToggle showLabel={false} />
<Button
variant="outline"
size="sm"
onClick={handleSignOut}
className="text-xs font-medium gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg hover:border-red-200 hover:bg-red-50/50 hover:text-red-600 dark:hover:bg-red-950/30 dark:hover:border-red-900 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
<span className="hidden min-[360px]:inline">Sign Out</span>
</Button>
</div>
</div>
</header>
{/* Main Content */}
<main className="relative z-10 flex-1 max-w-6xl w-full mx-auto p-4 sm:p-6 lg:p-8 space-y-6">
{/* Page Banner */}
<div className="relative overflow-hidden rounded-2xl border border-blue-500/20 bg-gradient-to-br from-card via-card to-cyan-500/5 p-6 sm:p-7 shadow-lg shadow-blue-950/5">
<div className="absolute top-0 right-0 h-40 w-40 bg-gradient-to-bl from-cyan-500/10 via-blue-500/5 to-transparent rounded-bl-full pointer-events-none" />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-semibold">
<ShieldCheck className="h-4 w-4" />
<span>Session Authenticated</span>
</div>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Integration &amp; Access Keys
</h2>
<p className="text-sm text-muted-foreground max-w-2xl font-medium">
Everything Dokploy, n8n, rclone or backup scripts need to connect endpoint details, ready-to-paste
environment variables and per-integration S3 keys.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
className="h-7 text-xs px-2 rounded-lg gap-1"
title="Refresh connection details"
>
<RotateCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
<span className="hidden min-[420px]:inline">Refresh</span>
</Button>
</div>
</div>
</div>
{error && (
<div className="p-4 rounded-2xl border border-destructive/30 bg-destructive/5 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<div className="text-sm font-bold text-destructive">Failed to load integration info</div>
<p className="text-xs text-muted-foreground">{error.message || "Unknown error occurred"}</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="h-7 text-xs mt-1">
Retry
</Button>
</div>
</div>
)}
{/* Connection details */}
<ConnectionDetailsCard info={info} isLoading={isLoading} />
{/* Access key management */}
<AccessKeyTable
accessKeys={accessKeys}
maxKeys={info?.limits.maxAccessKeys ?? 5}
isLoading={isLoading}
onCreateKey={() => setIsCreateOpen(true)}
onRotateKey={(key) => setRotatingKey(key)}
onRevokeKey={(key) => setRevokingKey(key)}
/>
</main>
{/* Dialogs */}
<CreateAccessKeyDialog
open={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onCreated={(key) => setNewKeyResult({ title: "Access Key Created", created: key })}
/>
<RotateAccessKeyDialog
open={Boolean(rotatingKey)}
onClose={() => setRotatingKey(null)}
accessKey={rotatingKey}
onRotated={handleRotated}
/>
<RevokeAccessKeyDialog
open={Boolean(revokingKey)}
onClose={() => setRevokingKey(null)}
accessKey={revokingKey}
isLastKey={accessKeys.length === 1 && revokingKey !== null}
/>
<NewKeyResultDialog
open={newKeyResult !== null}
onClose={() => setNewKeyResult(null)}
created={newKeyResult?.created}
title={newKeyResult?.title ?? ""}
/>
</div>
)
}
+9
View File
@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/integration"
element={
<ProtectedRoute>
<Integration />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>