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:
2026-02-10 09:42:41 -06:00
parent ab64583951
commit 6a5a4e4de8
10 changed files with 1841 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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";