mirror of
https://github.com/Nezumi-2711/s3-drive-storage-manage.git
synced 2026-09-22 20:02:01 +00:00
feat: add the config section for integrate
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user