feat(web): add credential management UI pages and components
Add credentials settings page, audit log page, CRUD dialog components (create, view, edit, rotate), credential card, dialog UI component, and API client for the M7-CredentialSecurity feature. Refs #346 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
247
apps/web/src/components/credentials/CreateCredentialDialog.tsx
Normal file
247
apps/web/src/components/credentials/CreateCredentialDialog.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CredentialType, CredentialScope } from "@/lib/api/credentials";
|
||||
|
||||
interface CreateCredentialDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: CreateCredentialFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateCredentialFormData {
|
||||
name: string;
|
||||
provider: string;
|
||||
type: CredentialType;
|
||||
scope: CredentialScope;
|
||||
value: string;
|
||||
description?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: "github", label: "GitHub" },
|
||||
{ value: "gitlab", label: "GitLab" },
|
||||
{ value: "bitbucket", label: "Bitbucket" },
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
export function CreateCredentialDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: CreateCredentialDialogProps): React.ReactElement {
|
||||
const [formData, setFormData] = useState<CreateCredentialFormData>({
|
||||
name: "",
|
||||
provider: "custom",
|
||||
type: CredentialType.API_KEY,
|
||||
scope: CredentialScope.USER,
|
||||
value: "",
|
||||
description: "",
|
||||
expiresAt: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validation
|
||||
if (!formData.name.trim()) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!formData.value.trim()) {
|
||||
setError("Value is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
// Reset form on success
|
||||
setFormData({
|
||||
name: "",
|
||||
provider: "custom",
|
||||
type: CredentialType.API_KEY,
|
||||
scope: CredentialScope.USER,
|
||||
value: "",
|
||||
description: "",
|
||||
expiresAt: "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create credential");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Credential</DialogTitle>
|
||||
<DialogDescription>
|
||||
Store a new credential securely. All values are encrypted at rest.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
}}
|
||||
placeholder="e.g., GitHub Personal Token"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, provider: value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="provider">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, type: value as CredentialType });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CredentialType.API_KEY}>API Key</SelectItem>
|
||||
<SelectItem value={CredentialType.ACCESS_TOKEN}>Access Token</SelectItem>
|
||||
<SelectItem value={CredentialType.OAUTH_TOKEN}>OAuth Token</SelectItem>
|
||||
<SelectItem value={CredentialType.PASSWORD}>Password</SelectItem>
|
||||
<SelectItem value={CredentialType.SECRET}>Secret</SelectItem>
|
||||
<SelectItem value={CredentialType.CUSTOM}>Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="value">Value *</Label>
|
||||
<Input
|
||||
id="value"
|
||||
type="password"
|
||||
value={formData.value}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, value: e.target.value });
|
||||
}}
|
||||
placeholder="Enter credential value"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This value will be encrypted and cannot be viewed in the list
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, description: e.target.value });
|
||||
}}
|
||||
placeholder="Optional description"
|
||||
disabled={isSubmitting}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiry Date */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="expiresAt">Target Date (optional)</Label>
|
||||
<Input
|
||||
id="expiresAt"
|
||||
type="date"
|
||||
value={formData.expiresAt}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, expiresAt: e.target.value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Consider rotating the credential by this date
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create Credential"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
131
apps/web/src/components/credentials/CredentialCard.tsx
Normal file
131
apps/web/src/components/credentials/CredentialCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Eye, Pencil, RotateCw, Trash2 } from "lucide-react";
|
||||
import type { Credential } from "@/lib/api/credentials";
|
||||
import { getExpiryStatus } from "@/lib/api/credentials";
|
||||
|
||||
interface CredentialCardProps {
|
||||
credential: Credential;
|
||||
onView: (credential: Credential) => void;
|
||||
onEdit: (credential: Credential) => void;
|
||||
onRotate: (credential: Credential) => void;
|
||||
onDelete: (credential: Credential) => void;
|
||||
}
|
||||
|
||||
export function CredentialCard({
|
||||
credential,
|
||||
onView,
|
||||
onEdit,
|
||||
onRotate,
|
||||
onDelete,
|
||||
}: CredentialCardProps): React.ReactElement {
|
||||
const expiryInfo = getExpiryStatus(credential.expiresAt);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{credential.name}
|
||||
{!credential.isActive && <Badge variant="outline">Inactive</Badge>}
|
||||
</CardTitle>
|
||||
{credential.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{credential.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onView(credential);
|
||||
}}
|
||||
title="View details"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onEdit(credential);
|
||||
}}
|
||||
title="Edit metadata"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onRotate(credential);
|
||||
}}
|
||||
title="Rotate value"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onDelete(credential);
|
||||
}}
|
||||
title="Remove credential"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{/* Provider & Type */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Provider:</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{credential.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{credential.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masked Value */}
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-muted-foreground">Value (masked)</p>
|
||||
<code className="block rounded bg-muted px-3 py-2 font-mono text-sm">
|
||||
{credential.maskedValue ?? "••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Expiry & Last Used */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{credential.expiresAt && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Target:</span>
|
||||
<Badge variant="outline" className={`ml-2 ${expiryInfo.className}`}>
|
||||
{expiryInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{credential.lastUsedAt && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Last used:</span>
|
||||
<span className="ml-2">{new Date(credential.lastUsedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
180
apps/web/src/components/credentials/EditCredentialDialog.tsx
Normal file
180
apps/web/src/components/credentials/EditCredentialDialog.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { Credential } from "@/lib/api/credentials";
|
||||
|
||||
interface EditCredentialDialogProps {
|
||||
credential: Credential | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (id: string, data: EditCredentialFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface EditCredentialFormData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export function EditCredentialDialog({
|
||||
credential,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: EditCredentialDialogProps): React.ReactElement {
|
||||
const [formData, setFormData] = useState<EditCredentialFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
expiresAt: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize form when credential changes
|
||||
useEffect(() => {
|
||||
if (credential) {
|
||||
const expiryDate = credential.expiresAt
|
||||
? new Date(credential.expiresAt).toISOString().split("T")[0]
|
||||
: "";
|
||||
setFormData({
|
||||
name: credential.name,
|
||||
description: credential.description ?? "",
|
||||
...(expiryDate && { expiresAt: expiryDate }),
|
||||
});
|
||||
}
|
||||
}, [credential]);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (!credential) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
// Validation
|
||||
if (!formData.name?.trim()) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(credential.id, formData);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update credential");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!credential) return <></>;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Credential</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update credential metadata. To change the value, use the Rotate option.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">Name *</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
}}
|
||||
placeholder="e.g., GitHub Personal Token"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={formData.description}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, description: e.target.value });
|
||||
}}
|
||||
placeholder="Optional description"
|
||||
disabled={isSubmitting}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiry Date */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-expiresAt">Target Date</Label>
|
||||
<Input
|
||||
id="edit-expiresAt"
|
||||
type="date"
|
||||
value={formData.expiresAt}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, expiresAt: e.target.value });
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Consider rotating the credential by this date
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Provider & Type (read-only) */}
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<p className="mb-2 text-sm font-medium">Read-Only Fields</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Provider:</span>
|
||||
<span className="ml-2">{credential.provider}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span className="ml-2">{credential.type.replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/components/credentials/RotateCredentialDialog.tsx
Normal file
158
apps/web/src/components/credentials/RotateCredentialDialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Credential } from "@/lib/api/credentials";
|
||||
|
||||
interface RotateCredentialDialogProps {
|
||||
credential: Credential | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (id: string, newValue: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RotateCredentialDialog({
|
||||
credential,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: RotateCredentialDialogProps): React.ReactElement {
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [confirmValue, setConfirmValue] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (!credential) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
// Validation
|
||||
if (!newValue.trim()) {
|
||||
setError("New value is required");
|
||||
return;
|
||||
}
|
||||
if (newValue !== confirmValue) {
|
||||
setError("Values do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(credential.id, newValue);
|
||||
setNewValue("");
|
||||
setConfirmValue("");
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to rotate credential");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!credential) return <></>;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
setNewValue("");
|
||||
setConfirmValue("");
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rotate Credential</DialogTitle>
|
||||
<DialogDescription>
|
||||
Replace the credential value with a new one. The old value will be permanently
|
||||
replaced.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Credential Info */}
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<p className="mb-1 font-medium">{credential.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{credential.provider} • {credential.type.replace(/_/g, " ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* New Value */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rotate-new-value">New Value *</Label>
|
||||
<Input
|
||||
id="rotate-new-value"
|
||||
type="password"
|
||||
value={newValue}
|
||||
onChange={(e) => {
|
||||
setNewValue(e.target.value);
|
||||
}}
|
||||
placeholder="Enter new credential value"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm Value */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rotate-confirm-value">Confirm New Value *</Label>
|
||||
<Input
|
||||
id="rotate-confirm-value"
|
||||
type="password"
|
||||
value={confirmValue}
|
||||
onChange={(e) => {
|
||||
setConfirmValue(e.target.value);
|
||||
}}
|
||||
placeholder="Re-enter new credential value"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<p className="text-sm text-orange-900">
|
||||
<strong>Note:</strong> This will permanently replace the existing credential value.
|
||||
The old value cannot be recovered after rotation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Rotating..." : "Rotate Credential"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
257
apps/web/src/components/credentials/ViewCredentialDialog.tsx
Normal file
257
apps/web/src/components/credentials/ViewCredentialDialog.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||
import type { Credential } from "@/lib/api/credentials";
|
||||
import { getExpiryStatus } from "@/lib/api/credentials";
|
||||
|
||||
interface ViewCredentialDialogProps {
|
||||
credential: Credential | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRevealValue: (id: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const AUTO_HIDE_DURATION_MS = 30000; // 30 seconds
|
||||
|
||||
export function ViewCredentialDialog({
|
||||
credential,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRevealValue,
|
||||
}: ViewCredentialDialogProps): React.ReactElement {
|
||||
const [isRevealing, setIsRevealing] = useState(false);
|
||||
const [revealedValue, setRevealedValue] = useState<string | null>(null);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [autoHideTimer, setAutoHideTimer] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cleanup on unmount or dialog close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRevealedValue(null);
|
||||
setShowWarning(false);
|
||||
setError(null);
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer);
|
||||
setAutoHideTimer(null);
|
||||
}
|
||||
}
|
||||
}, [open, autoHideTimer]);
|
||||
|
||||
const handleRevealClick = useCallback((): void => {
|
||||
setShowWarning(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmReveal = useCallback(async (): Promise<void> => {
|
||||
if (!credential) return;
|
||||
|
||||
setIsRevealing(true);
|
||||
setError(null);
|
||||
setShowWarning(false);
|
||||
|
||||
try {
|
||||
const value = await onRevealValue(credential.id);
|
||||
setRevealedValue(value);
|
||||
|
||||
// Auto-hide after 30 seconds
|
||||
const timerId = window.setTimeout(() => {
|
||||
setRevealedValue(null);
|
||||
setAutoHideTimer(null);
|
||||
}, AUTO_HIDE_DURATION_MS);
|
||||
setAutoHideTimer(timerId);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to reveal credential. You may have exceeded the rate limit (10 requests/minute)."
|
||||
);
|
||||
} finally {
|
||||
setIsRevealing(false);
|
||||
}
|
||||
}, [credential, onRevealValue]);
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
if (!revealedValue) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealedValue);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}, [revealedValue]);
|
||||
|
||||
const handleHideValue = useCallback((): void => {
|
||||
setRevealedValue(null);
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer);
|
||||
setAutoHideTimer(null);
|
||||
}
|
||||
}, [autoHideTimer]);
|
||||
|
||||
if (!credential) return <></>;
|
||||
|
||||
const expiryInfo = getExpiryStatus(credential.expiresAt);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{credential.name}</DialogTitle>
|
||||
<DialogDescription>View credential details and reveal value</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Provider & Type */}
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Provider</p>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{credential.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Type</p>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{credential.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{credential.description && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Description</p>
|
||||
<p className="mt-1 text-sm">{credential.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masked Value */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Masked Value</p>
|
||||
<code className="mt-1 block rounded bg-muted px-3 py-2 font-mono text-sm">
|
||||
{credential.maskedValue ?? "••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Revealed Value */}
|
||||
{revealedValue && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-orange-900">Decrypted Value</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="h-8 text-orange-900 hover:bg-orange-100"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleHideValue}
|
||||
className="h-8 text-orange-900 hover:bg-orange-100"
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="block rounded bg-white px-3 py-2 font-mono text-sm text-orange-900 break-all">
|
||||
{revealedValue}
|
||||
</code>
|
||||
<p className="mt-2 text-xs text-orange-700">Value will auto-hide in 30 seconds</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning before reveal */}
|
||||
{showWarning && !revealedValue && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-yellow-900">Reveal Credential Value?</p>
|
||||
<p className="mb-4 text-sm text-yellow-700">
|
||||
This will decrypt and display the credential value in plaintext. This action is
|
||||
logged. The value will auto-hide after 30 seconds.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowWarning(false);
|
||||
}}
|
||||
disabled={isRevealing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmReveal} disabled={isRevealing}>
|
||||
{isRevealing ? "Revealing..." : "Confirm Reveal"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reveal Button */}
|
||||
{!showWarning && !revealedValue && (
|
||||
<Button onClick={handleRevealClick} variant="outline" className="w-full">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Reveal Value
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiry Status */}
|
||||
{credential.expiresAt && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Target Date</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className={expiryInfo.className}>
|
||||
{expiryInfo.label}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(credential.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Used */}
|
||||
{credential.lastUsedAt && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Last Used</p>
|
||||
<p className="mt-1 text-sm">{new Date(credential.lastUsedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{credential.rotatedAt && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Last Rotated</p>
|
||||
<p className="mt-1 text-sm">{new Date(credential.rotatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/credentials/index.ts
Normal file
7
apps/web/src/components/credentials/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { CredentialCard } from "./CredentialCard";
|
||||
export { CreateCredentialDialog } from "./CreateCredentialDialog";
|
||||
export type { CreateCredentialFormData } from "./CreateCredentialDialog";
|
||||
export { EditCredentialDialog } from "./EditCredentialDialog";
|
||||
export type { EditCredentialFormData } from "./EditCredentialDialog";
|
||||
export { RotateCredentialDialog } from "./RotateCredentialDialog";
|
||||
export { ViewCredentialDialog } from "./ViewCredentialDialog";
|
||||
Reference in New Issue
Block a user