From 6a5a4e4de806e9847d8c22c40540f97ca827eaa8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Feb 2026 09:42:41 -0600 Subject: [PATCH] feat(web): add credential management UI pages and components Add credentials settings page, audit log page, CRUD dialog components (create, view, edit, rotate), credential card, dialog UI component, and API client for the M7-CredentialSecurity feature. Refs #346 Co-Authored-By: Claude Opus 4.6 --- .../settings/credentials/audit/page.tsx | 361 ++++++++++++++++++ .../settings/credentials/page.tsx | 98 +++++ .../credentials/CreateCredentialDialog.tsx | 247 ++++++++++++ .../components/credentials/CredentialCard.tsx | 131 +++++++ .../credentials/EditCredentialDialog.tsx | 180 +++++++++ .../credentials/RotateCredentialDialog.tsx | 158 ++++++++ .../credentials/ViewCredentialDialog.tsx | 257 +++++++++++++ apps/web/src/components/credentials/index.ts | 7 + apps/web/src/components/ui/dialog.tsx | 128 +++++++ apps/web/src/lib/api/credentials.ts | 274 +++++++++++++ 10 files changed, 1841 insertions(+) create mode 100644 apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx create mode 100644 apps/web/src/app/(authenticated)/settings/credentials/page.tsx create mode 100644 apps/web/src/components/credentials/CreateCredentialDialog.tsx create mode 100644 apps/web/src/components/credentials/CredentialCard.tsx create mode 100644 apps/web/src/components/credentials/EditCredentialDialog.tsx create mode 100644 apps/web/src/components/credentials/RotateCredentialDialog.tsx create mode 100644 apps/web/src/components/credentials/ViewCredentialDialog.tsx create mode 100644 apps/web/src/components/credentials/index.ts create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/lib/api/credentials.ts 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 */} +
+ +