chore: upgrade Node.js runtime to v24 across codebase #419
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowLeft, Filter, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
||||
|
||||
const ACTIVITY_ACTIONS = [
|
||||
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
||||
{ value: "CREDENTIAL_ACCESSED", label: "Accessed" },
|
||||
{ value: "CREDENTIAL_ROTATED", label: "Rotated" },
|
||||
{ value: "CREDENTIAL_REVOKED", label: "Revoked" },
|
||||
{ value: "UPDATED", label: "Updated" },
|
||||
];
|
||||
|
||||
interface FilterState {
|
||||
action?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export default function CredentialAuditPage(): React.ReactElement {
|
||||
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(20);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
const [hasFilters, setHasFilters] = useState(false);
|
||||
|
||||
// TODO: Get workspace ID from context/auth
|
||||
const workspaceId = "default-workspace-id"; // Placeholder
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs();
|
||||
}, [page, filters]);
|
||||
|
||||
async function loadLogs(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchCredentialAuditLog(workspaceId, {
|
||||
...filters,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setLogs(response.data);
|
||||
setTotalPages(response.meta.totalPages);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load audit logs");
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange(filterKey: keyof FilterState, value: string | undefined): void {
|
||||
const newFilters = { ...filters, [filterKey]: value };
|
||||
if (!value) {
|
||||
const { [filterKey]: _, ...rest } = newFilters;
|
||||
setFilters(rest);
|
||||
setHasFilters(Object.keys(rest).length > 0);
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setFilters(newFilters);
|
||||
setHasFilters(Object.keys(newFilters).length > 0);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function handleClearFilters(): void {
|
||||
setFilters({});
|
||||
setHasFilters(false);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function formatTimestamp(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getActionBadgeColor(action: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
CREDENTIAL_CREATED: "bg-green-100 text-green-800",
|
||||
CREDENTIAL_ACCESSED: "bg-blue-100 text-blue-800",
|
||||
CREDENTIAL_ROTATED: "bg-purple-100 text-purple-800",
|
||||
CREDENTIAL_REVOKED: "bg-red-100 text-red-800",
|
||||
UPDATED: "bg-yellow-100 text-yellow-800",
|
||||
};
|
||||
return colors[action] ?? "bg-gray-100 text-gray-800";
|
||||
}
|
||||
|
||||
function getActionLabel(action: string): string {
|
||||
const label = ACTIVITY_ACTIONS.find((a) => a.value === action)?.label;
|
||||
return label ?? action;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Link href="/settings/credentials">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Credentials
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Credential Audit Log</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
View all activities related to your stored credentials
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-gray-600" />
|
||||
<CardTitle className="text-lg">Filter Logs</CardTitle>
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Action Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Activity Type</label>
|
||||
<Select
|
||||
value={filters.action ?? ""}
|
||||
onValueChange={(value) => {
|
||||
handleFilterChange("action", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All activities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All activities</SelectItem>
|
||||
{ACTIVITY_ACTIONS.map((action) => (
|
||||
<SelectItem key={action.value} value={action.value}>
|
||||
{action.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Start Date Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">From Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.startDate ?? ""}
|
||||
onChange={(e) => {
|
||||
handleFilterChange("startDate", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End Date Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">To Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.endDate ?? ""}
|
||||
onChange={(e) => {
|
||||
handleFilterChange("endDate", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audit Logs List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity History</CardTitle>
|
||||
<CardDescription>
|
||||
{logs.length > 0
|
||||
? `Showing ${String((page - 1) * limit + 1)}-${String(Math.min(page * limit, logs.length))} entries`
|
||||
: "No activities found"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-gray-500">Loading audit logs...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-lg">No audit logs found</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{hasFilters ? "Try adjusting your filters" : "No credential activities yet"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Desktop view */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">Timestamp</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">Activity</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">User</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-600 text-xs whitespace-nowrap">
|
||||
{formatTimestamp(log.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 rounded text-xs font-medium ${getActionBadgeColor(log.action)}`}
|
||||
>
|
||||
{getActionLabel(log.action)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{log.user.name ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{log.user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-600">
|
||||
<div className="text-ellipsis overflow-hidden">
|
||||
{(log.details.name as string) && (
|
||||
<p>
|
||||
<span className="font-medium">Name:</span>{" "}
|
||||
{log.details.name as string}
|
||||
</p>
|
||||
)}
|
||||
{(log.details.provider as string) && (
|
||||
<p>
|
||||
<span className="font-medium">Provider:</span>{" "}
|
||||
{log.details.provider as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile view */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 rounded text-xs font-medium ${getActionBadgeColor(log.action)}`}
|
||||
>
|
||||
{getActionLabel(log.action)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTimestamp(log.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<p className="font-medium text-gray-900">{log.user.name ?? "Unknown"}</p>
|
||||
<p className="text-xs text-gray-600">{log.user.email}</p>
|
||||
</div>
|
||||
{((log.details.name as string) || (log.details.provider as string)) && (
|
||||
<div className="text-xs text-gray-600 border-t border-gray-200 pt-2 mt-2">
|
||||
{(log.details.name as string) && (
|
||||
<p>
|
||||
<span className="font-medium">Name:</span> {log.details.name as string}
|
||||
</p>
|
||||
)}
|
||||
{(log.details.provider as string) && (
|
||||
<p>
|
||||
<span className="font-medium">Provider:</span>{" "}
|
||||
{log.details.provider as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => {
|
||||
setPage(Math.max(1, page - 1));
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => {
|
||||
setPage(Math.min(totalPages, page + 1));
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, History } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { fetchCredentials, type Credential } from "@/lib/api/credentials";
|
||||
|
||||
export default function CredentialsPage(): React.ReactElement {
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const workspaceId = "default-workspace-id";
|
||||
|
||||
useEffect(() => {
|
||||
void loadCredentials();
|
||||
}, []);
|
||||
|
||||
async function loadCredentials(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchCredentials(workspaceId);
|
||||
setCredentials(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load credentials");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Credentials</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Securely store and manage API keys, tokens, and passwords
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button disabled>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Credential
|
||||
</Button>
|
||||
<Link href="/settings/credentials/audit">
|
||||
<Button variant="outline">
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Audit Log
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && <div className="mb-4 p-4 bg-red-50 text-red-800 rounded-md">{error}</div>}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Loading credentials...</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-gray-500 mb-4">No credentials found</p>
|
||||
<Button disabled>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add First Credential
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Credentials feature coming soon.</p>
|
||||
<p className="mt-2">
|
||||
<Link href="/settings/credentials/audit">
|
||||
<Button variant="link" className="p-0">
|
||||
View Audit Log →
|
||||
</Button>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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";
|
||||
128
apps/web/src/components/ui/dialog.tsx
Normal file
128
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface DialogProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DialogTriggerProps {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export interface DialogContentProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DialogHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DialogFooterProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DialogTitleProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DialogDescriptionProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DialogContext = React.createContext<{
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}>({});
|
||||
|
||||
export function Dialog({ open, onOpenChange, children }: DialogProps): React.JSX.Element {
|
||||
const contextValue: { open?: boolean; onOpenChange?: (open: boolean) => void } = {};
|
||||
if (open !== undefined) {
|
||||
contextValue.open = open;
|
||||
}
|
||||
if (onOpenChange !== undefined) {
|
||||
contextValue.onOpenChange = onOpenChange;
|
||||
}
|
||||
|
||||
return <DialogContext.Provider value={contextValue}>{children}</DialogContext.Provider>;
|
||||
}
|
||||
|
||||
export function DialogTrigger({ children, asChild }: DialogTriggerProps): React.JSX.Element {
|
||||
const { onOpenChange } = React.useContext(DialogContext);
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
onClick: () => onOpenChange?.(true),
|
||||
} as React.HTMLAttributes<HTMLElement>);
|
||||
}
|
||||
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function DialogContent({
|
||||
children,
|
||||
className = "",
|
||||
}: DialogContentProps): React.JSX.Element | null {
|
||||
const { open, onOpenChange } = React.useContext(DialogContext);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => onOpenChange?.(false)} />
|
||||
|
||||
{/* Dialog Content */}
|
||||
<div
|
||||
className={`relative z-50 w-full max-w-lg rounded-lg bg-white p-6 shadow-lg ${className}`}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeader({ children, className = "" }: DialogHeaderProps): React.JSX.Element {
|
||||
return <div className={`mb-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function DialogFooter({ children, className = "" }: DialogFooterProps): React.JSX.Element {
|
||||
return <div className={`mt-4 flex justify-end gap-2 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function DialogTitle({ children, className = "" }: DialogTitleProps): React.JSX.Element {
|
||||
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
|
||||
}
|
||||
|
||||
export function DialogDescription({
|
||||
children,
|
||||
className = "",
|
||||
}: DialogDescriptionProps): React.JSX.Element {
|
||||
return <p className={`text-sm text-gray-600 ${className}`}>{children}</p>;
|
||||
}
|
||||
|
||||
export const DialogPortal = ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<>{children}</>
|
||||
);
|
||||
export const DialogOverlay = ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<>{children}</>
|
||||
);
|
||||
export const DialogClose = ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||
<>{children}</>
|
||||
);
|
||||
274
apps/web/src/lib/api/credentials.ts
Normal file
274
apps/web/src/lib/api/credentials.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Credentials API Client
|
||||
* Handles credential-related API requests
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
||||
|
||||
/**
|
||||
* Credential type enum (matches backend)
|
||||
*/
|
||||
export enum CredentialType {
|
||||
API_KEY = "API_KEY",
|
||||
OAUTH_TOKEN = "OAUTH_TOKEN",
|
||||
ACCESS_TOKEN = "ACCESS_TOKEN",
|
||||
SECRET = "SECRET",
|
||||
PASSWORD = "PASSWORD",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential scope enum (matches backend)
|
||||
*/
|
||||
export enum CredentialScope {
|
||||
USER = "USER",
|
||||
WORKSPACE = "WORKSPACE",
|
||||
SYSTEM = "SYSTEM",
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential response interface
|
||||
*/
|
||||
export interface Credential {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string | null;
|
||||
name: string;
|
||||
provider: string;
|
||||
type: CredentialType;
|
||||
scope: CredentialScope;
|
||||
maskedValue: string | null;
|
||||
description: string | null;
|
||||
expiresAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
metadata: Record<string, unknown>;
|
||||
isActive: boolean;
|
||||
rotatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create credential DTO
|
||||
*/
|
||||
export interface CreateCredentialDto {
|
||||
name: string;
|
||||
provider: string;
|
||||
type: CredentialType;
|
||||
scope?: CredentialScope;
|
||||
value: string;
|
||||
description?: string;
|
||||
expiresAt?: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credential DTO
|
||||
*/
|
||||
export interface UpdateCredentialDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
expiresAt?: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query credential DTO
|
||||
*/
|
||||
export interface QueryCredentialDto {
|
||||
provider?: string;
|
||||
type?: CredentialType;
|
||||
scope?: CredentialScope;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential value response
|
||||
*/
|
||||
export interface CredentialValueResponse {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all credentials with optional filters
|
||||
* NEVER returns plaintext values - only maskedValue
|
||||
*/
|
||||
export async function fetchCredentials(
|
||||
workspaceId: string,
|
||||
query?: QueryCredentialDto
|
||||
): Promise<ApiResponse<Credential[]>> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.provider) params.append("provider", query.provider);
|
||||
if (query?.type) params.append("type", query.type);
|
||||
if (query?.scope) params.append("scope", query.scope);
|
||||
if (query?.isActive !== undefined) params.append("isActive", String(query.isActive));
|
||||
if (query?.page) params.append("page", String(query.page));
|
||||
if (query?.limit) params.append("limit", String(query.limit));
|
||||
|
||||
const endpoint = `/api/credentials${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
return apiGet<ApiResponse<Credential[]>>(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single credential by ID
|
||||
* NEVER returns plaintext value - only maskedValue
|
||||
*/
|
||||
export async function fetchCredential(id: string, workspaceId: string): Promise<Credential> {
|
||||
return apiGet<Credential>(`/api/credentials/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and return credential value
|
||||
* CRITICAL: This is the ONLY endpoint that returns plaintext
|
||||
* Rate limited to 10 requests per minute per user
|
||||
*/
|
||||
export async function fetchCredentialValue(
|
||||
id: string,
|
||||
workspaceId: string
|
||||
): Promise<CredentialValueResponse> {
|
||||
return apiGet<CredentialValueResponse>(`/api/credentials/${id}/value`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new credential
|
||||
*/
|
||||
export async function createCredential(
|
||||
workspaceId: string,
|
||||
data: CreateCredentialDto
|
||||
): Promise<Credential> {
|
||||
return apiPost<Credential>("/api/credentials", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credential metadata (NOT the value itself)
|
||||
* Use rotateCredential to change the credential value
|
||||
*/
|
||||
export async function updateCredential(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: UpdateCredentialDto
|
||||
): Promise<Credential> {
|
||||
return apiPatch<Credential>(`/api/credentials/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace credential value with new encrypted value
|
||||
*/
|
||||
export async function rotateCredential(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
newValue: string
|
||||
): Promise<Credential> {
|
||||
return apiPost<Credential>(`/api/credentials/${id}/rotate`, { newValue }, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete credential (set isActive = false)
|
||||
*/
|
||||
export async function deleteCredential(
|
||||
id: string,
|
||||
workspaceId: string
|
||||
): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/credentials/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit log entry interface
|
||||
*/
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
entityId: string;
|
||||
createdAt: string;
|
||||
details: Record<string, unknown>;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for audit log
|
||||
*/
|
||||
export interface QueryAuditLogDto {
|
||||
credentialId?: string;
|
||||
action?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch credential audit logs with optional filters
|
||||
* Shows all credential-related activities for the workspace
|
||||
*/
|
||||
export async function fetchCredentialAuditLog(
|
||||
workspaceId: string,
|
||||
query?: QueryAuditLogDto
|
||||
): Promise<{
|
||||
data: AuditLogEntry[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.credentialId) params.append("credentialId", query.credentialId);
|
||||
if (query?.action) params.append("action", query.action);
|
||||
if (query?.startDate) params.append("startDate", query.startDate);
|
||||
if (query?.endDate) params.append("endDate", query.endDate);
|
||||
if (query?.page) params.append("page", String(query.page));
|
||||
if (query?.limit) params.append("limit", String(query.limit));
|
||||
|
||||
const endpoint = `/api/credentials/audit${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
return apiGet(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider icon name for lucide-react
|
||||
*/
|
||||
export function getProviderIcon(provider: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
github: "Github",
|
||||
gitlab: "Gitlab",
|
||||
bitbucket: "Bitbucket",
|
||||
openai: "Brain",
|
||||
custom: "Key",
|
||||
};
|
||||
return icons[provider.toLowerCase()] ?? "Key";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format expiry status for PDA-friendly display
|
||||
*/
|
||||
export function getExpiryStatus(expiresAt: Date | null): {
|
||||
status: "active" | "approaching" | "past";
|
||||
label: string;
|
||||
className: string;
|
||||
} {
|
||||
if (!expiresAt) {
|
||||
return { status: "active", label: "No expiry", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiry = new Date(expiresAt);
|
||||
const daysUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
return { status: "past", label: "Past target date", className: "text-orange-600" };
|
||||
} else if (daysUntilExpiry <= 7) {
|
||||
return { status: "approaching", label: "Approaching target", className: "text-yellow-600" };
|
||||
} else {
|
||||
return {
|
||||
status: "active",
|
||||
label: `Active (${String(daysUntilExpiry)}d)`,
|
||||
className: "text-green-600",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user