Skip to content

Commit 41b3e6e

Browse files
committed
feat: add reusable ConfirmationDialog component to replace window.confirm()
Closes #610 - Create ConfirmationDialog component with configurable title, message, confirm/cancel labels, variant (default/danger/warning), and loading state - Replace window.confirm() in DocumentSidebar.tsx with styled dialog for document deletion confirmation - Replace window.confirm() in ApiKeyManager.tsx with styled dialog for API key revocation confirmation - Built on existing @base-ui/react/dialog primitives
1 parent cd74ff7 commit 41b3e6e

3 files changed

Lines changed: 243 additions & 89 deletions

File tree

frontend/src/components/auth/ApiKeyManager.tsx

Lines changed: 108 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "@/components/ui/dialog";
1313
import { api } from "@/lib/api";
1414
import { Key, Plus, Trash2, Copy, Check } from "lucide-react";
15+
import { ConfirmationDialog } from "@/components/ui/confirm-dialog";
1516

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

2931
const fetchKeys = async () => {
3032
try {
@@ -58,9 +60,14 @@ export default function ApiKeyManager() {
5860
}
5961
};
6062

61-
const revokeKey = async (id: string) => {
62-
if (!confirm("Are you sure you want to revoke this key? Any integrations using it will immediately break.")) return;
63-
63+
const revokeKey = (id: string) => {
64+
setRevokeConfirmKeyId(id);
65+
};
66+
67+
const executeRevokeKey = async () => {
68+
if (!revokeConfirmKeyId) return;
69+
const id = revokeConfirmKeyId;
70+
setRevokeConfirmKeyId(null);
6471
try {
6572
await api.delete(`/api/v1/auth/api-keys/${id}`);
6673
setKeys((prev) => prev.filter((k) => k.id !== id));
@@ -78,97 +85,111 @@ export default function ApiKeyManager() {
7885
};
7986

8087
return (
81-
<Dialog onOpenChange={(open) => { if (!open) setNewKey(null); }}>
82-
<DialogTrigger
83-
render={
84-
<button
85-
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"
86-
aria-label="Open API key manager"
87-
>
88-
<Key className="mr-2 h-4 w-4" />
89-
<span>API Keys</span>
90-
</button>
91-
}
92-
/>
93-
<DialogContent className="max-w-2xl sm:rounded-2xl border-border/40 p-6 md:p-8 bg-background/95 backdrop-blur-xl shadow-2xl">
88+
<>
89+
<Dialog onOpenChange={(open) => { if (!open) setNewKey(null); }}>
90+
<DialogTrigger
91+
render={
92+
<button
93+
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"
94+
aria-label="Open API key manager"
95+
>
96+
<Key className="mr-2 h-4 w-4" />
97+
<span>API Keys</span>
98+
</button>
99+
}
100+
/>
101+
<DialogContent className="max-w-2xl sm:rounded-2xl border-border/40 p-6 md:p-8 bg-background/95 backdrop-blur-xl shadow-2xl">
94102

95-
<DialogHeader>
96-
<DialogTitle className="text-2xl font-bold tracking-tight">API Keys</DialogTitle>
97-
<DialogDescription className="text-sm text-muted-foreground mt-1.5">
98-
Manage API keys to access the RAG engine programmatically from your own applications or scripts.
99-
</DialogDescription>
100-
</DialogHeader>
103+
<DialogHeader>
104+
<DialogTitle className="text-2xl font-bold tracking-tight">API Keys</DialogTitle>
105+
<DialogDescription className="text-sm text-muted-foreground mt-1.5">
106+
Manage API keys to access the RAG engine programmatically from your own applications or scripts.
107+
</DialogDescription>
108+
</DialogHeader>
101109

102-
{newKey && (
103-
<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">
104-
<h4 className="font-semibold text-primary flex items-center gap-2">
105-
<Key className="w-4 h-4" /> Save your new API key
106-
</h4>
107-
<p className="text-sm text-muted-foreground">
108-
Please copy this key and store it somewhere safe. For security reasons, you will <strong>never</strong> be able to view it again.
109-
</p>
110-
<div className="flex items-center gap-2 mt-2">
111-
<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">
112-
{newKey}
113-
</code>
114-
<Button
115-
onClick={copyToClipboard}
116-
variant={copied ? "default" : "secondary"}
117-
className="shrink-0 shadow-sm"
118-
aria-label={copied ? "API key copied" : "Copy new API key"}
119-
>
120-
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
121-
{copied ? "Copied!" : "Copy"}
122-
</Button>
110+
{newKey && (
111+
<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">
112+
<h4 className="font-semibold text-primary flex items-center gap-2">
113+
<Key className="w-4 h-4" /> Save your new API key
114+
</h4>
115+
<p className="text-sm text-muted-foreground">
116+
Please copy this key and store it somewhere safe. For security reasons, you will <strong>never</strong> be able to view it again.
117+
</p>
118+
<div className="flex items-center gap-2 mt-2">
119+
<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">
120+
{newKey}
121+
</code>
122+
<Button
123+
onClick={copyToClipboard}
124+
variant={copied ? "default" : "secondary"}
125+
className="shrink-0 shadow-sm"
126+
aria-label={copied ? "API key copied" : "Copy new API key"}
127+
>
128+
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
129+
{copied ? "Copied!" : "Copy"}
130+
</Button>
131+
</div>
123132
</div>
124-
</div>
125-
)}
133+
)}
126134

127-
<div className="space-y-4 mt-6">
128-
<div className="flex items-center justify-between">
129-
<h3 className="text-sm font-medium text-foreground/80 uppercase tracking-wider">Active Keys</h3>
130-
<Button onClick={generateKey} disabled={loading} size="sm" className="rounded-full shadow-sm hover:shadow-md transition-shadow">
131-
<Plus className="w-4 h-4 mr-1.5" />
132-
Generate New Key
133-
</Button>
134-
</div>
135+
<div className="space-y-4 mt-6">
136+
<div className="flex items-center justify-between">
137+
<h3 className="text-sm font-medium text-foreground/80 uppercase tracking-wider">Active Keys</h3>
138+
<Button onClick={generateKey} disabled={loading} size="sm" className="rounded-full shadow-sm hover:shadow-md transition-shadow">
139+
<Plus className="w-4 h-4 mr-1.5" />
140+
Generate New Key
141+
</Button>
142+
</div>
135143

136-
<div className="rounded-xl border border-border/50 bg-card overflow-hidden shadow-sm">
137-
{keys.length === 0 ? (
138-
<div className="p-8 text-center text-sm text-muted-foreground bg-muted/20">
139-
<Key className="w-8 h-8 mx-auto mb-3 opacity-20" />
140-
You don&apos;t have any API keys yet.
141-
</div>
142-
) : (
143-
<div className="divide-y divide-border/50">
144-
{keys.map((key) => (
145-
<div key={key.id} className="flex items-center justify-between p-4 hover:bg-muted/30 transition-colors group">
146-
<div className="space-y-1">
147-
<div className="font-mono text-sm font-medium tracking-tight">
148-
{key.key_prefix}••••••••••••••••••••••
149-
</div>
150-
<div className="text-xs text-muted-foreground flex gap-4">
151-
<span>Created: {new Date(key.created_at).toLocaleDateString()}</span>
152-
<span>Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"}</span>
144+
<div className="rounded-xl border border-border/50 bg-card overflow-hidden shadow-sm">
145+
{keys.length === 0 ? (
146+
<div className="p-8 text-center text-sm text-muted-foreground bg-muted/20">
147+
<Key className="w-8 h-8 mx-auto mb-3 opacity-20" />
148+
You don&apos;t have any API keys yet.
149+
</div>
150+
) : (
151+
<div className="divide-y divide-border/50">
152+
{keys.map((key) => (
153+
<div key={key.id} className="flex items-center justify-between p-4 hover:bg-muted/30 transition-colors group">
154+
<div className="space-y-1">
155+
<div className="font-mono text-sm font-medium tracking-tight">
156+
{key.key_prefix}••••••••••••••••••••••
157+
</div>
158+
<div className="text-xs text-muted-foreground flex gap-4">
159+
<span>Created: {new Date(key.created_at).toLocaleDateString()}</span>
160+
<span>Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"}</span>
161+
</div>
153162
</div>
163+
<Button
164+
variant="ghost"
165+
size="icon"
166+
onClick={() => revokeKey(key.id)}
167+
className="text-destructive/70 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-all"
168+
title="Revoke key"
169+
aria-label={`Revoke API key ${key.key_prefix}`}
170+
>
171+
<Trash2 className="w-4 h-4" />
172+
</Button>
154173
</div>
155-
<Button
156-
variant="ghost"
157-
size="icon"
158-
onClick={() => revokeKey(key.id)}
159-
className="text-destructive/70 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-all"
160-
title="Revoke key"
161-
aria-label={`Revoke API key ${key.key_prefix}`}
162-
>
163-
<Trash2 className="w-4 h-4" />
164-
</Button>
165-
</div>
166-
))}
167-
</div>
168-
)}
174+
))}
175+
</div>
176+
)}
177+
</div>
169178
</div>
170-
</div>
171-
</DialogContent>
172-
</Dialog>
179+
</DialogContent>
180+
</Dialog>
181+
182+
{/* Revoke Key Confirmation Dialog */}
183+
<ConfirmationDialog
184+
open={revokeConfirmKeyId !== null}
185+
onOpenChange={(open) => { if (!open) setRevokeConfirmKeyId(null); }}
186+
title="Revoke API Key"
187+
message="Are you sure you want to revoke this key? Any integrations using it will immediately break."
188+
confirmLabel="Revoke"
189+
cancelLabel="Cancel"
190+
variant="danger"
191+
onConfirm={executeRevokeKey}
192+
/>
193+
</>
173194
);
174195
}

frontend/src/components/document/DocumentSidebar.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Settings } from "lucide-react";
1818
import DocumentSettings from "./DocumentSettings";
1919
import DocumentCard from "./DocumentCard";
2020
import { toast } from "sonner";
21+
import { ConfirmationDialog } from "@/components/ui/confirm-dialog";
2122

2223
interface Props {
2324
documents: DocInfo[];
@@ -63,6 +64,7 @@ export default function DocumentSidebar({
6364
const [uploadProgress, setUploadProgress] = useState(0);
6465
const [uploadError, setUploadError] = useState("");
6566
const [deleting, setDeleting] = useState<string | null>(null);
67+
const [deleteConfirmDocId, setDeleteConfirmDocId] = useState<string | null>(null);
6668
const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
6769
const [editingDocId, setEditingDocId] = useState<string | null>(null);
6870
const [draftName, setDraftName] = useState("");
@@ -137,10 +139,16 @@ export default function DocumentSidebar({
137139
disabled: uploading,
138140
});
139141

140-
const handleDelete = async (docId: string, e: React.MouseEvent) => {
142+
const handleDelete = (docId: string, e: React.MouseEvent) => {
141143
e.stopPropagation();
142-
if (!confirm(t("documents.deleteConfirm"))) return;
144+
setDeleteConfirmDocId(docId);
145+
};
146+
147+
const executeDelete = async () => {
148+
if (!deleteConfirmDocId) return;
149+
const docId = deleteConfirmDocId;
143150
setDeleting(docId);
151+
setDeleteConfirmDocId(null);
144152
try {
145153
await api.delete(`/api/v1/documents/${docId}`);
146154
await onDocumentsChange();
@@ -466,6 +474,19 @@ export default function DocumentSidebar({
466474
</div>
467475
)}
468476
</ScrollArea>
477+
{/* Delete Confirmation Dialog */}
478+
<ConfirmationDialog
479+
open={deleteConfirmDocId !== null}
480+
onOpenChange={(open) => { if (!open) setDeleteConfirmDocId(null); }}
481+
title={t("documents.deleteTitle") || "Delete Document"}
482+
message={t("documents.deleteConfirm")}
483+
confirmLabel={t("documents.deleteConfirmLabel") || "Delete"}
484+
cancelLabel={t("documents.deleteCancelLabel") || "Cancel"}
485+
variant="danger"
486+
loading={deleting !== null}
487+
onConfirm={executeDelete}
488+
/>
489+
469490
{/* Settings Modal */}
470491
{/* 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. */}
471492
{settingsDoc && (
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { Loader2 } from "lucide-react"
5+
6+
import { cn } from "@/lib/utils"
7+
import { Button } from "@/components/ui/button"
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogDescription,
12+
DialogFooter,
13+
DialogHeader,
14+
DialogTitle,
15+
} from "@/components/ui/dialog"
16+
17+
const variantStyles = {
18+
danger: {
19+
confirmVariant: "destructive" as const,
20+
icon: "text-destructive",
21+
border: "data-closed:animate-out",
22+
},
23+
warning: {
24+
confirmVariant: "secondary" as const,
25+
icon: "text-amber-500",
26+
border: "data-closed:animate-out",
27+
},
28+
default: {
29+
confirmVariant: "default" as const,
30+
icon: "text-primary",
31+
border: "data-closed:animate-out",
32+
},
33+
} as const
34+
35+
export interface ConfirmationDialogProps {
36+
open: boolean
37+
onOpenChange: (open: boolean) => void
38+
title: string
39+
message: string | React.ReactNode
40+
confirmLabel?: string
41+
cancelLabel?: string
42+
variant?: "default" | "danger" | "warning"
43+
loading?: boolean
44+
onConfirm: () => void | Promise<void>
45+
}
46+
47+
export function ConfirmationDialog({
48+
open,
49+
onOpenChange,
50+
title,
51+
message,
52+
confirmLabel = "Confirm",
53+
cancelLabel = "Cancel",
54+
variant = "default",
55+
loading = false,
56+
onConfirm,
57+
}: ConfirmationDialogProps) {
58+
const style = variantStyles[variant]
59+
60+
const handleConfirm = async () => {
61+
await onConfirm()
62+
}
63+
64+
return (
65+
<Dialog open={open} onOpenChange={onOpenChange}>
66+
<DialogContent
67+
className={cn(
68+
"sm:max-w-md",
69+
variant === "danger" && "sm:border-destructive/20",
70+
variant === "warning" && "sm:border-amber-500/20",
71+
)}
72+
showCloseButton={!loading}
73+
>
74+
<DialogHeader>
75+
<DialogTitle
76+
className={cn(
77+
variant === "danger" && "text-destructive",
78+
variant === "warning" && "text-amber-600 dark:text-amber-400",
79+
)}
80+
>
81+
{title}
82+
</DialogTitle>
83+
<DialogDescription asChild={typeof message !== "string"}>
84+
{typeof message === "string" ? (
85+
<span className="text-sm text-muted-foreground">{message}</span>
86+
) : (
87+
message
88+
)}
89+
</DialogDescription>
90+
</DialogHeader>
91+
92+
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
93+
<Button
94+
variant="outline"
95+
onClick={() => onOpenChange(false)}
96+
disabled={loading}
97+
>
98+
{cancelLabel}
99+
</Button>
100+
<Button
101+
variant={style.confirmVariant}
102+
onClick={handleConfirm}
103+
disabled={loading}
104+
>
105+
{loading && <Loader2 className="mr-1.5 size-4 animate-spin" />}
106+
{confirmLabel}
107+
</Button>
108+
</DialogFooter>
109+
</DialogContent>
110+
</Dialog>
111+
)
112+
}

0 commit comments

Comments
 (0)