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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user