Three root causes resolved: 1. .woodpecker/web.yml: build-shared step was missing @mosaic/ui build, causing 10 test suite failures + 20 typecheck errors (TS2307) 2. apps/orchestrator/Dockerfile: find -o without parentheses only deleted last pattern's matches, leaving spec files with test fixture secrets that triggered 5 Trivy false positives (3 CRITICAL, 2 HIGH) 3. 9 web files had untyped event handler parameters (e) causing 49 lint errors and 19 typecheck errors — added React.ChangeEvent<T> types Verification: lint 0 errors, typecheck 0 errors, tests 73/73 suites pass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
7.7 KiB
TypeScript
248 lines
7.7 KiB
TypeScript
"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: React.ChangeEvent<HTMLInputElement>) => {
|
|
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: React.ChangeEvent<HTMLInputElement>) => {
|
|
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: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
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: React.ChangeEvent<HTMLInputElement>) => {
|
|
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>
|
|
);
|
|
}
|