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 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 09:42:41 -06:00
parent ab64583951
commit 6a5a4e4de8
10 changed files with 1841 additions and 0 deletions

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