chore: upgrade Node.js runtime to v24 across codebase #419

Merged
jason.woltje merged 438 commits from fix/auth-frontend-remediation into main 2026-02-17 01:04:47 +00:00
10 changed files with 1841 additions and 0 deletions
Showing only changes of commit 6a5a4e4de8 - Show all commits

View File

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

View File

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

View File

@@ -0,0 +1,247 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CredentialType, CredentialScope } from "@/lib/api/credentials";
interface CreateCredentialDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateCredentialFormData) => Promise<void>;
}
export interface CreateCredentialFormData {
name: string;
provider: string;
type: CredentialType;
scope: CredentialScope;
value: string;
description?: string;
expiresAt?: string;
}
const PROVIDERS = [
{ value: "github", label: "GitHub" },
{ value: "gitlab", label: "GitLab" },
{ value: "bitbucket", label: "Bitbucket" },
{ value: "openai", label: "OpenAI" },
{ value: "custom", label: "Custom" },
];
export function CreateCredentialDialog({
open,
onOpenChange,
onSubmit,
}: CreateCredentialDialogProps): React.ReactElement {
const [formData, setFormData] = useState<CreateCredentialFormData>({
name: "",
provider: "custom",
type: CredentialType.API_KEY,
scope: CredentialScope.USER,
value: "",
description: "",
expiresAt: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
setError(null);
// Validation
if (!formData.name.trim()) {
setError("Name is required");
return;
}
if (!formData.value.trim()) {
setError("Value is required");
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
// Reset form on success
setFormData({
name: "",
provider: "custom",
type: CredentialType.API_KEY,
scope: CredentialScope.USER,
value: "",
description: "",
expiresAt: "",
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create credential");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Add Credential</DialogTitle>
<DialogDescription>
Store a new credential securely. All values are encrypted at rest.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Name */}
<div className="grid gap-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
}}
placeholder="e.g., GitHub Personal Token"
disabled={isSubmitting}
/>
</div>
{/* Provider */}
<div className="grid gap-2">
<Label htmlFor="provider">Provider</Label>
<Select
value={formData.provider}
onValueChange={(value) => {
setFormData({ ...formData, provider: value });
}}
disabled={isSubmitting}
>
<SelectTrigger id="provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type */}
<div className="grid gap-2">
<Label htmlFor="type">Type</Label>
<Select
value={formData.type}
onValueChange={(value) => {
setFormData({ ...formData, type: value as CredentialType });
}}
disabled={isSubmitting}
>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CredentialType.API_KEY}>API Key</SelectItem>
<SelectItem value={CredentialType.ACCESS_TOKEN}>Access Token</SelectItem>
<SelectItem value={CredentialType.OAUTH_TOKEN}>OAuth Token</SelectItem>
<SelectItem value={CredentialType.PASSWORD}>Password</SelectItem>
<SelectItem value={CredentialType.SECRET}>Secret</SelectItem>
<SelectItem value={CredentialType.CUSTOM}>Custom</SelectItem>
</SelectContent>
</Select>
</div>
{/* Value */}
<div className="grid gap-2">
<Label htmlFor="value">Value *</Label>
<Input
id="value"
type="password"
value={formData.value}
onChange={(e) => {
setFormData({ ...formData, value: e.target.value });
}}
placeholder="Enter credential value"
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
This value will be encrypted and cannot be viewed in the list
</p>
</div>
{/* Description */}
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value });
}}
placeholder="Optional description"
disabled={isSubmitting}
rows={2}
/>
</div>
{/* Expiry Date */}
<div className="grid gap-2">
<Label htmlFor="expiresAt">Target Date (optional)</Label>
<Input
id="expiresAt"
type="date"
value={formData.expiresAt}
onChange={(e) => {
setFormData({ ...formData, expiresAt: e.target.value });
}}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Consider rotating the credential by this date
</p>
</div>
{/* Error Display */}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Eye, Pencil, RotateCw, Trash2 } from "lucide-react";
import type { Credential } from "@/lib/api/credentials";
import { getExpiryStatus } from "@/lib/api/credentials";
interface CredentialCardProps {
credential: Credential;
onView: (credential: Credential) => void;
onEdit: (credential: Credential) => void;
onRotate: (credential: Credential) => void;
onDelete: (credential: Credential) => void;
}
export function CredentialCard({
credential,
onView,
onEdit,
onRotate,
onDelete,
}: CredentialCardProps): React.ReactElement {
const expiryInfo = getExpiryStatus(credential.expiresAt);
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{credential.name}
{!credential.isActive && <Badge variant="outline">Inactive</Badge>}
</CardTitle>
{credential.description && (
<p className="mt-1 text-sm text-muted-foreground">{credential.description}</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
onView(credential);
}}
title="View details"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onEdit(credential);
}}
title="Edit metadata"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onRotate(credential);
}}
title="Rotate value"
>
<RotateCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onDelete(credential);
}}
title="Remove credential"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{/* Provider & Type */}
<div className="flex gap-4 text-sm">
<div>
<span className="text-muted-foreground">Provider:</span>
<Badge variant="outline" className="ml-2">
{credential.provider}
</Badge>
</div>
<div>
<span className="text-muted-foreground">Type:</span>
<Badge variant="outline" className="ml-2">
{credential.type.replace(/_/g, " ")}
</Badge>
</div>
</div>
{/* Masked Value */}
<div>
<p className="mb-1 text-sm text-muted-foreground">Value (masked)</p>
<code className="block rounded bg-muted px-3 py-2 font-mono text-sm">
{credential.maskedValue ?? "••••••••"}
</code>
</div>
{/* Expiry & Last Used */}
<div className="flex flex-wrap gap-4 text-sm">
{credential.expiresAt && (
<div>
<span className="text-muted-foreground">Target:</span>
<Badge variant="outline" className={`ml-2 ${expiryInfo.className}`}>
{expiryInfo.label}
</Badge>
</div>
)}
{credential.lastUsedAt && (
<div>
<span className="text-muted-foreground">Last used:</span>
<span className="ml-2">{new Date(credential.lastUsedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { Credential } from "@/lib/api/credentials";
interface EditCredentialDialogProps {
credential: Credential | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (id: string, data: EditCredentialFormData) => Promise<void>;
}
export interface EditCredentialFormData {
name?: string;
description?: string;
expiresAt?: string;
}
export function EditCredentialDialog({
credential,
open,
onOpenChange,
onSubmit,
}: EditCredentialDialogProps): React.ReactElement {
const [formData, setFormData] = useState<EditCredentialFormData>({
name: "",
description: "",
expiresAt: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize form when credential changes
useEffect(() => {
if (credential) {
const expiryDate = credential.expiresAt
? new Date(credential.expiresAt).toISOString().split("T")[0]
: "";
setFormData({
name: credential.name,
description: credential.description ?? "",
...(expiryDate && { expiresAt: expiryDate }),
});
}
}, [credential]);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
if (!credential) return;
setError(null);
// Validation
if (!formData.name?.trim()) {
setError("Name is required");
return;
}
setIsSubmitting(true);
try {
await onSubmit(credential.id, formData);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update credential");
} finally {
setIsSubmitting(false);
}
};
if (!credential) return <></>;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Edit Credential</DialogTitle>
<DialogDescription>
Update credential metadata. To change the value, use the Rotate option.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Name */}
<div className="grid gap-2">
<Label htmlFor="edit-name">Name *</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
}}
placeholder="e.g., GitHub Personal Token"
disabled={isSubmitting}
/>
</div>
{/* Description */}
<div className="grid gap-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value });
}}
placeholder="Optional description"
disabled={isSubmitting}
rows={3}
/>
</div>
{/* Expiry Date */}
<div className="grid gap-2">
<Label htmlFor="edit-expiresAt">Target Date</Label>
<Input
id="edit-expiresAt"
type="date"
value={formData.expiresAt}
onChange={(e) => {
setFormData({ ...formData, expiresAt: e.target.value });
}}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Consider rotating the credential by this date
</p>
</div>
{/* Provider & Type (read-only) */}
<div className="rounded-lg bg-muted p-3">
<p className="mb-2 text-sm font-medium">Read-Only Fields</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Provider:</span>
<span className="ml-2">{credential.provider}</span>
</div>
<div>
<span className="text-muted-foreground">Type:</span>
<span className="ml-2">{credential.type.replace(/_/g, " ")}</span>
</div>
</div>
</div>
{/* Error Display */}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Credential } from "@/lib/api/credentials";
interface RotateCredentialDialogProps {
credential: Credential | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (id: string, newValue: string) => Promise<void>;
}
export function RotateCredentialDialog({
credential,
open,
onOpenChange,
onSubmit,
}: RotateCredentialDialogProps): React.ReactElement {
const [newValue, setNewValue] = useState("");
const [confirmValue, setConfirmValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
if (!credential) return;
setError(null);
// Validation
if (!newValue.trim()) {
setError("New value is required");
return;
}
if (newValue !== confirmValue) {
setError("Values do not match");
return;
}
setIsSubmitting(true);
try {
await onSubmit(credential.id, newValue);
setNewValue("");
setConfirmValue("");
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to rotate credential");
} finally {
setIsSubmitting(false);
}
};
if (!credential) return <></>;
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open) {
setNewValue("");
setConfirmValue("");
setError(null);
}
}}
>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Rotate Credential</DialogTitle>
<DialogDescription>
Replace the credential value with a new one. The old value will be permanently
replaced.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Credential Info */}
<div className="rounded-lg bg-muted p-3">
<p className="mb-1 font-medium">{credential.name}</p>
<p className="text-sm text-muted-foreground">
{credential.provider} {credential.type.replace(/_/g, " ")}
</p>
</div>
{/* New Value */}
<div className="grid gap-2">
<Label htmlFor="rotate-new-value">New Value *</Label>
<Input
id="rotate-new-value"
type="password"
value={newValue}
onChange={(e) => {
setNewValue(e.target.value);
}}
placeholder="Enter new credential value"
disabled={isSubmitting}
/>
</div>
{/* Confirm Value */}
<div className="grid gap-2">
<Label htmlFor="rotate-confirm-value">Confirm New Value *</Label>
<Input
id="rotate-confirm-value"
type="password"
value={confirmValue}
onChange={(e) => {
setConfirmValue(e.target.value);
}}
placeholder="Re-enter new credential value"
disabled={isSubmitting}
/>
</div>
{/* Warning */}
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<p className="text-sm text-orange-900">
<strong>Note:</strong> This will permanently replace the existing credential value.
The old value cannot be recovered after rotation.
</p>
</div>
{/* Error Display */}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Rotating..." : "Rotate Credential"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import type { Credential } from "@/lib/api/credentials";
import { getExpiryStatus } from "@/lib/api/credentials";
interface ViewCredentialDialogProps {
credential: Credential | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onRevealValue: (id: string) => Promise<string>;
}
const AUTO_HIDE_DURATION_MS = 30000; // 30 seconds
export function ViewCredentialDialog({
credential,
open,
onOpenChange,
onRevealValue,
}: ViewCredentialDialogProps): React.ReactElement {
const [isRevealing, setIsRevealing] = useState(false);
const [revealedValue, setRevealedValue] = useState<string | null>(null);
const [showWarning, setShowWarning] = useState(false);
const [autoHideTimer, setAutoHideTimer] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cleanup on unmount or dialog close
useEffect(() => {
if (!open) {
setRevealedValue(null);
setShowWarning(false);
setError(null);
if (autoHideTimer) {
clearTimeout(autoHideTimer);
setAutoHideTimer(null);
}
}
}, [open, autoHideTimer]);
const handleRevealClick = useCallback((): void => {
setShowWarning(true);
}, []);
const handleConfirmReveal = useCallback(async (): Promise<void> => {
if (!credential) return;
setIsRevealing(true);
setError(null);
setShowWarning(false);
try {
const value = await onRevealValue(credential.id);
setRevealedValue(value);
// Auto-hide after 30 seconds
const timerId = window.setTimeout(() => {
setRevealedValue(null);
setAutoHideTimer(null);
}, AUTO_HIDE_DURATION_MS);
setAutoHideTimer(timerId);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to reveal credential. You may have exceeded the rate limit (10 requests/minute)."
);
} finally {
setIsRevealing(false);
}
}, [credential, onRevealValue]);
const handleCopy = useCallback(async (): Promise<void> => {
if (!revealedValue) return;
try {
await navigator.clipboard.writeText(revealedValue);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [revealedValue]);
const handleHideValue = useCallback((): void => {
setRevealedValue(null);
if (autoHideTimer) {
clearTimeout(autoHideTimer);
setAutoHideTimer(null);
}
}, [autoHideTimer]);
if (!credential) return <></>;
const expiryInfo = getExpiryStatus(credential.expiresAt);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>{credential.name}</DialogTitle>
<DialogDescription>View credential details and reveal value</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Provider & Type */}
<div className="flex gap-4">
<div>
<p className="text-sm text-muted-foreground">Provider</p>
<Badge variant="outline" className="mt-1">
{credential.provider}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Type</p>
<Badge variant="outline" className="mt-1">
{credential.type.replace(/_/g, " ")}
</Badge>
</div>
</div>
{/* Description */}
{credential.description && (
<div>
<p className="text-sm text-muted-foreground">Description</p>
<p className="mt-1 text-sm">{credential.description}</p>
</div>
)}
{/* Masked Value */}
<div>
<p className="text-sm text-muted-foreground">Masked Value</p>
<code className="mt-1 block rounded bg-muted px-3 py-2 font-mono text-sm">
{credential.maskedValue ?? "••••••••"}
</code>
</div>
{/* Revealed Value */}
{revealedValue && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-4">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium text-orange-900">Decrypted Value</p>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="h-8 text-orange-900 hover:bg-orange-100"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleHideValue}
className="h-8 text-orange-900 hover:bg-orange-100"
>
<EyeOff className="h-4 w-4" />
</Button>
</div>
</div>
<code className="block rounded bg-white px-3 py-2 font-mono text-sm text-orange-900 break-all">
{revealedValue}
</code>
<p className="mt-2 text-xs text-orange-700">Value will auto-hide in 30 seconds</p>
</div>
)}
{/* Warning before reveal */}
{showWarning && !revealedValue && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="mb-2 text-sm font-medium text-yellow-900">Reveal Credential Value?</p>
<p className="mb-4 text-sm text-yellow-700">
This will decrypt and display the credential value in plaintext. This action is
logged. The value will auto-hide after 30 seconds.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setShowWarning(false);
}}
disabled={isRevealing}
>
Cancel
</Button>
<Button size="sm" onClick={handleConfirmReveal} disabled={isRevealing}>
{isRevealing ? "Revealing..." : "Confirm Reveal"}
</Button>
</div>
</div>
)}
{/* Reveal Button */}
{!showWarning && !revealedValue && (
<Button onClick={handleRevealClick} variant="outline" className="w-full">
<Eye className="mr-2 h-4 w-4" />
Reveal Value
</Button>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Expiry Status */}
{credential.expiresAt && (
<div>
<p className="text-sm text-muted-foreground">Target Date</p>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className={expiryInfo.className}>
{expiryInfo.label}
</Badge>
<span className="text-sm text-muted-foreground">
{new Date(credential.expiresAt).toLocaleDateString()}
</span>
</div>
</div>
)}
{/* Last Used */}
{credential.lastUsedAt && (
<div>
<p className="text-sm text-muted-foreground">Last Used</p>
<p className="mt-1 text-sm">{new Date(credential.lastUsedAt).toLocaleString()}</p>
</div>
)}
{/* Metadata */}
{credential.rotatedAt && (
<div>
<p className="text-sm text-muted-foreground">Last Rotated</p>
<p className="mt-1 text-sm">{new Date(credential.rotatedAt).toLocaleString()}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,7 @@
export { CredentialCard } from "./CredentialCard";
export { CreateCredentialDialog } from "./CreateCredentialDialog";
export type { CreateCredentialFormData } from "./CreateCredentialDialog";
export { EditCredentialDialog } from "./EditCredentialDialog";
export type { EditCredentialFormData } from "./EditCredentialDialog";
export { RotateCredentialDialog } from "./RotateCredentialDialog";
export { ViewCredentialDialog } from "./ViewCredentialDialog";

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

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