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