Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 108 additions & 87 deletions frontend/src/components/auth/ApiKeyManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { api } from "@/lib/api";
import { Key, Plus, Trash2, Copy, Check } from "lucide-react";
import { ConfirmationDialog } from "@/components/ui/confirm-dialog";

interface ApiKey {
id: string;
Expand All @@ -25,6 +26,7 @@ export default function ApiKeyManager() {
const [newKey, setNewKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [revokeConfirmKeyId, setRevokeConfirmKeyId] = useState<string | null>(null);

const fetchKeys = async () => {
try {
Expand Down Expand Up @@ -58,9 +60,14 @@ export default function ApiKeyManager() {
}
};

const revokeKey = async (id: string) => {
if (!confirm("Are you sure you want to revoke this key? Any integrations using it will immediately break.")) return;

const revokeKey = (id: string) => {
setRevokeConfirmKeyId(id);
};

const executeRevokeKey = async () => {
if (!revokeConfirmKeyId) return;
const id = revokeConfirmKeyId;
setRevokeConfirmKeyId(null);
try {
await api.delete(`/api/v1/auth/api-keys/${id}`);
setKeys((prev) => prev.filter((k) => k.id !== id));
Expand All @@ -78,97 +85,111 @@ export default function ApiKeyManager() {
};

return (
<Dialog onOpenChange={(open) => { if (!open) setNewKey(null); }}>
<DialogTrigger
render={
<button
className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Open API key manager"
>
<Key className="mr-2 h-4 w-4" />
<span>API Keys</span>
</button>
}
/>
<DialogContent className="max-w-2xl sm:rounded-2xl border-border/40 p-6 md:p-8 bg-background/95 backdrop-blur-xl shadow-2xl">
<>
<Dialog onOpenChange={(open) => { if (!open) setNewKey(null); }}>
<DialogTrigger
render={
<button
className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Open API key manager"
>
<Key className="mr-2 h-4 w-4" />
<span>API Keys</span>
</button>
}
/>
<DialogContent className="max-w-2xl sm:rounded-2xl border-border/40 p-6 md:p-8 bg-background/95 backdrop-blur-xl shadow-2xl">

<DialogHeader>
<DialogTitle className="text-2xl font-bold tracking-tight">API Keys</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground mt-1.5">
Manage API keys to access the RAG engine programmatically from your own applications or scripts.
</DialogDescription>
</DialogHeader>
<DialogHeader>
<DialogTitle className="text-2xl font-bold tracking-tight">API Keys</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground mt-1.5">
Manage API keys to access the RAG engine programmatically from your own applications or scripts.
</DialogDescription>
</DialogHeader>

{newKey && (
<div className="my-6 p-5 border border-primary/20 bg-primary/5 rounded-xl space-y-3 animate-in fade-in zoom-in-95 duration-300">
<h4 className="font-semibold text-primary flex items-center gap-2">
<Key className="w-4 h-4" /> Save your new API key
</h4>
<p className="text-sm text-muted-foreground">
Please copy this key and store it somewhere safe. For security reasons, you will <strong>never</strong> be able to view it again.
</p>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 bg-background/80 border border-border/50 px-4 py-2.5 rounded-lg text-sm font-mono break-all text-foreground shadow-inner">
{newKey}
</code>
<Button
onClick={copyToClipboard}
variant={copied ? "default" : "secondary"}
className="shrink-0 shadow-sm"
aria-label={copied ? "API key copied" : "Copy new API key"}
>
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
{copied ? "Copied!" : "Copy"}
</Button>
{newKey && (
<div className="my-6 p-5 border border-primary/20 bg-primary/5 rounded-xl space-y-3 animate-in fade-in zoom-in-95 duration-300">
<h4 className="font-semibold text-primary flex items-center gap-2">
<Key className="w-4 h-4" /> Save your new API key
</h4>
<p className="text-sm text-muted-foreground">
Please copy this key and store it somewhere safe. For security reasons, you will <strong>never</strong> be able to view it again.
</p>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 bg-background/80 border border-border/50 px-4 py-2.5 rounded-lg text-sm font-mono break-all text-foreground shadow-inner">
{newKey}
</code>
<Button
onClick={copyToClipboard}
variant={copied ? "default" : "secondary"}
className="shrink-0 shadow-sm"
aria-label={copied ? "API key copied" : "Copy new API key"}
>
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
</div>
)}
)}

<div className="space-y-4 mt-6">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground/80 uppercase tracking-wider">Active Keys</h3>
<Button onClick={generateKey} disabled={loading} size="sm" className="rounded-full shadow-sm hover:shadow-md transition-shadow">
<Plus className="w-4 h-4 mr-1.5" />
Generate New Key
</Button>
</div>
<div className="space-y-4 mt-6">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground/80 uppercase tracking-wider">Active Keys</h3>
<Button onClick={generateKey} disabled={loading} size="sm" className="rounded-full shadow-sm hover:shadow-md transition-shadow">
<Plus className="w-4 h-4 mr-1.5" />
Generate New Key
</Button>
</div>

<div className="rounded-xl border border-border/50 bg-card overflow-hidden shadow-sm">
{keys.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground bg-muted/20">
<Key className="w-8 h-8 mx-auto mb-3 opacity-20" />
You don&apos;t have any API keys yet.
</div>
) : (
<div className="divide-y divide-border/50">
{keys.map((key) => (
<div key={key.id} className="flex items-center justify-between p-4 hover:bg-muted/30 transition-colors group">
<div className="space-y-1">
<div className="font-mono text-sm font-medium tracking-tight">
{key.key_prefix}β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’
</div>
<div className="text-xs text-muted-foreground flex gap-4">
<span>Created: {new Date(key.created_at).toLocaleDateString()}</span>
<span>Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"}</span>
<div className="rounded-xl border border-border/50 bg-card overflow-hidden shadow-sm">
{keys.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground bg-muted/20">
<Key className="w-8 h-8 mx-auto mb-3 opacity-20" />
You don&apos;t have any API keys yet.
</div>
) : (
<div className="divide-y divide-border/50">
{keys.map((key) => (
<div key={key.id} className="flex items-center justify-between p-4 hover:bg-muted/30 transition-colors group">
<div className="space-y-1">
<div className="font-mono text-sm font-medium tracking-tight">
{key.key_prefix}β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’
</div>
<div className="text-xs text-muted-foreground flex gap-4">
<span>Created: {new Date(key.created_at).toLocaleDateString()}</span>
<span>Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => revokeKey(key.id)}
className="text-destructive/70 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-all"
title="Revoke key"
aria-label={`Revoke API key ${key.key_prefix}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => revokeKey(key.id)}
className="text-destructive/70 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-all"
title="Revoke key"
aria-label={`Revoke API key ${key.key_prefix}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
))}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>

{/* Revoke Key Confirmation Dialog */}
<ConfirmationDialog
open={revokeConfirmKeyId !== null}
onOpenChange={(open) => { if (!open) setRevokeConfirmKeyId(null); }}
title="Revoke API Key"
message="Are you sure you want to revoke this key? Any integrations using it will immediately break."
confirmLabel="Revoke"
cancelLabel="Cancel"
variant="danger"
onConfirm={executeRevokeKey}
/>
</>
);
}
25 changes: 23 additions & 2 deletions frontend/src/components/document/DocumentSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Settings } from "lucide-react";
import DocumentSettings from "./DocumentSettings";
import DocumentCard from "./DocumentCard";
import { toast } from "sonner";
import { ConfirmationDialog } from "@/components/ui/confirm-dialog";

interface Props {
documents: DocInfo[];
Expand Down Expand Up @@ -63,6 +64,7 @@ export default function DocumentSidebar({
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState("");
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteConfirmDocId, setDeleteConfirmDocId] = useState<string | null>(null);
const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
const [editingDocId, setEditingDocId] = useState<string | null>(null);
const [draftName, setDraftName] = useState("");
Expand Down Expand Up @@ -137,10 +139,16 @@ export default function DocumentSidebar({
disabled: uploading,
});

const handleDelete = async (docId: string, e: React.MouseEvent) => {
const handleDelete = (docId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(t("documents.deleteConfirm"))) return;
setDeleteConfirmDocId(docId);
};

const executeDelete = async () => {
if (!deleteConfirmDocId) return;
const docId = deleteConfirmDocId;
setDeleting(docId);
setDeleteConfirmDocId(null);
try {
await api.delete(`/api/v1/documents/${docId}`);
await onDocumentsChange();
Expand Down Expand Up @@ -466,6 +474,19 @@ export default function DocumentSidebar({
</div>
)}
</ScrollArea>
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
open={deleteConfirmDocId !== null}
onOpenChange={(open) => { if (!open) setDeleteConfirmDocId(null); }}
title={t("documents.deleteTitle") || "Delete Document"}
message={t("documents.deleteConfirm")}
confirmLabel={t("documents.deleteConfirmLabel") || "Delete"}
cancelLabel={t("documents.deleteCancelLabel") || "Cancel"}
variant="danger"
loading={deleting !== null}
onConfirm={executeDelete}
/>

{/* Settings Modal */}
{/* The DocumentSettings component is rendered here and controlled by the settingsDoc state. When a user clicks the settings button for a document, it sets that document in settingsDoc, which opens the modal. The modal can then call onDocumentsChange to refresh the list after saving settings. */}
{settingsDoc && (
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/components/ui/confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client"

import * as React from "react"
import { Loader2 } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"

const variantStyles = {
danger: {
confirmVariant: "destructive" as const,
icon: "text-destructive",
border: "data-closed:animate-out",
},
warning: {
confirmVariant: "secondary" as const,
icon: "text-amber-500",
border: "data-closed:animate-out",
},
default: {
confirmVariant: "default" as const,
icon: "text-primary",
border: "data-closed:animate-out",
},
} as const

export interface ConfirmationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
message: string | React.ReactNode
confirmLabel?: string
cancelLabel?: string
variant?: "default" | "danger" | "warning"
loading?: boolean
onConfirm: () => void | Promise<void>
}

export function ConfirmationDialog({
open,
onOpenChange,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
loading = false,
onConfirm,
}: ConfirmationDialogProps) {
const style = variantStyles[variant]

const handleConfirm = async () => {
await onConfirm()
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"sm:max-w-md",
variant === "danger" && "sm:border-destructive/20",
variant === "warning" && "sm:border-amber-500/20",
)}
showCloseButton={!loading}
>
<DialogHeader>
<DialogTitle
className={cn(
variant === "danger" && "text-destructive",
variant === "warning" && "text-amber-600 dark:text-amber-400",
)}
>
{title}
</DialogTitle>
<DialogDescription asChild={typeof message !== "string"}>
{typeof message === "string" ? (
<span className="text-sm text-muted-foreground">{message}</span>
) : (
message
)}
</DialogDescription>
</DialogHeader>

<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{cancelLabel}
</Button>
<Button
variant={style.confirmVariant}
onClick={handleConfirm}
disabled={loading}
>
{loading && <Loader2 className="mr-1.5 size-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading