diff --git a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx new file mode 100644 index 0000000..d7b9724 --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [limit] = useState(20); + const [totalPages, setTotalPages] = useState(0); + const [filters, setFilters] = useState({}); + 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 { + 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 = { + 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 ( +
+
+
+ + + +
+
+

Credential Audit Log

+

+ View all activities related to your stored credentials +

+
+
+ + {/* Filters Card */} + + +
+
+ + Filter Logs +
+ {hasFilters && ( + + )} +
+
+ +
+ {/* Action Filter */} +
+ + +
+ + {/* Start Date Filter */} +
+ + { + handleFilterChange("startDate", e.target.value); + }} + /> +
+ + {/* End Date Filter */} +
+ + { + handleFilterChange("endDate", e.target.value); + }} + /> +
+
+
+
+ + {/* Audit Logs List */} + + + Activity History + + {logs.length > 0 + ? `Showing ${String((page - 1) * limit + 1)}-${String(Math.min(page * limit, logs.length))} entries` + : "No activities found"} + + + + {isLoading ? ( +
+

Loading audit logs...

+
+ ) : error ? ( +
+

{error}

+
+ ) : logs.length === 0 ? ( +
+
+

No audit logs found

+

+ {hasFilters ? "Try adjusting your filters" : "No credential activities yet"} +

+
+
+ ) : ( +
+ {/* Desktop view */} +
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + ))} + +
TimestampActivityUserDetails
+ {formatTimestamp(log.createdAt)} + + + {getActionLabel(log.action)} + + +
+

+ {log.user.name ?? "Unknown"} +

+

{log.user.email}

+
+
+
+ {(log.details.name as string) && ( +

+ Name:{" "} + {log.details.name as string} +

+ )} + {(log.details.provider as string) && ( +

+ Provider:{" "} + {log.details.provider as string} +

+ )} +
+
+
+ + {/* Mobile view */} +
+ {logs.map((log) => ( +
+
+ + {getActionLabel(log.action)} + + + {formatTimestamp(log.createdAt)} + +
+
+

{log.user.name ?? "Unknown"}

+

{log.user.email}

+
+ {((log.details.name as string) || (log.details.provider as string)) && ( +
+ {(log.details.name as string) && ( +

+ Name: {log.details.name as string} +

+ )} + {(log.details.provider as string) && ( +

+ Provider:{" "} + {log.details.provider as string} +

+ )} +
+ )} +
+ ))} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page} of {totalPages} +
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx new file mode 100644 index 0000000..5df0c66 --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const workspaceId = "default-workspace-id"; + + useEffect(() => { + void loadCredentials(); + }, []); + + async function loadCredentials(): Promise { + 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 ( +
+ {/* Header */} +
+
+
+

Credentials

+

+ Securely store and manage API keys, tokens, and passwords +

+
+
+ + + + +
+
+
+ + {/* Error Display */} + {error &&
{error}
} + + {/* Loading State */} + {isLoading ? ( +
+

Loading credentials...

+
+ ) : credentials.length === 0 ? ( + + +

No credentials found

+ +
+
+ ) : ( +
+ + +
+

Credentials feature coming soon.

+

+ + + +

+
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/credentials/CreateCredentialDialog.tsx b/apps/web/src/components/credentials/CreateCredentialDialog.tsx new file mode 100644 index 0000000..da83628 --- /dev/null +++ b/apps/web/src/components/credentials/CreateCredentialDialog.tsx @@ -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; +} + +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({ + name: "", + provider: "custom", + type: CredentialType.API_KEY, + scope: CredentialScope.USER, + value: "", + description: "", + expiresAt: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.SyntheticEvent): Promise => { + 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 ( + + +
+ + Add Credential + + Store a new credential securely. All values are encrypted at rest. + + + +
+ {/* Name */} +
+ + { + setFormData({ ...formData, name: e.target.value }); + }} + placeholder="e.g., GitHub Personal Token" + disabled={isSubmitting} + /> +
+ + {/* Provider */} +
+ + +
+ + {/* Type */} +
+ + +
+ + {/* Value */} +
+ + { + setFormData({ ...formData, value: e.target.value }); + }} + placeholder="Enter credential value" + disabled={isSubmitting} + /> +

+ This value will be encrypted and cannot be viewed in the list +

+
+ + {/* Description */} +
+ +