re-uploading work
This commit is contained in:
647
web/src/components/forms/PackForm.tsx
Normal file
647
web/src/components/forms/PackForm.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreatePack, useUpdatePack } from "@/hooks/usePacks";
|
||||
import type { PackResponse } from "@/api";
|
||||
import { labelToRef } from "@/lib/format-utils";
|
||||
import SchemaBuilder from "@/components/common/SchemaBuilder";
|
||||
import ParamSchemaForm from "@/components/common/ParamSchemaForm";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
|
||||
interface PackFormProps {
|
||||
pack?: PackResponse;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const isEditing = !!pack;
|
||||
|
||||
// Store initial/database state for reset
|
||||
const initialConfSchema = pack?.conf_schema || {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
};
|
||||
const initialConfig = pack?.config || {};
|
||||
|
||||
// Form state
|
||||
const [ref, setRef] = useState(pack?.ref || "");
|
||||
const [label, setLabel] = useState(pack?.label || "");
|
||||
const [description, setDescription] = useState(pack?.description || "");
|
||||
const [version, setVersion] = useState(pack?.version || "1.0.0");
|
||||
const [tags, setTags] = useState(pack?.tags?.join(", ") || "");
|
||||
const [runtimeDeps, setRuntimeDeps] = useState(
|
||||
pack?.runtime_deps?.join(", ") || "",
|
||||
);
|
||||
const [isStandard, setIsStandard] = useState(pack?.is_standard ?? false);
|
||||
|
||||
const [configValues, setConfigValues] =
|
||||
useState<Record<string, any>>(initialConfig);
|
||||
const [confSchema, setConfSchema] =
|
||||
useState<Record<string, any>>(initialConfSchema);
|
||||
const [meta, setMeta] = useState(
|
||||
pack?.meta ? JSON.stringify(pack.meta, null, 2) : "{}",
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Mutations
|
||||
const createPack = useCreatePack();
|
||||
const updatePack = useUpdatePack();
|
||||
|
||||
// Check if schema has properties
|
||||
const hasSchemaProperties =
|
||||
confSchema?.properties && Object.keys(confSchema.properties).length > 0;
|
||||
|
||||
// Sync config values when schema changes (for ad-hoc packs only)
|
||||
useEffect(() => {
|
||||
if (!isStandard && hasSchemaProperties) {
|
||||
// Get current schema property names
|
||||
const schemaKeys = Object.keys(confSchema.properties || {});
|
||||
|
||||
// Create new config with only keys that exist in schema
|
||||
const syncedConfig: Record<string, any> = {};
|
||||
schemaKeys.forEach((key) => {
|
||||
if (configValues[key] !== undefined) {
|
||||
// Preserve existing value
|
||||
syncedConfig[key] = configValues[key];
|
||||
} else {
|
||||
// Use default from schema if available
|
||||
const defaultValue = confSchema.properties[key]?.default;
|
||||
if (defaultValue !== undefined) {
|
||||
syncedConfig[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only update if there's a difference
|
||||
const currentKeys = Object.keys(configValues).sort().join(",");
|
||||
const syncedKeys = Object.keys(syncedConfig).sort().join(",");
|
||||
if (currentKeys !== syncedKeys) {
|
||||
setConfigValues(syncedConfig);
|
||||
}
|
||||
}
|
||||
}, [confSchema, isStandard]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!label.trim()) {
|
||||
newErrors.label = "Label is required";
|
||||
}
|
||||
|
||||
if (!ref.trim()) {
|
||||
newErrors.ref = "Reference is required";
|
||||
} else if (!/^[a-z0-9_-]+$/.test(ref)) {
|
||||
newErrors.ref =
|
||||
"Reference must contain only lowercase letters, numbers, hyphens, and underscores";
|
||||
}
|
||||
|
||||
if (!version.trim()) {
|
||||
newErrors.version = "Version is required";
|
||||
}
|
||||
|
||||
// Validate conf_schema
|
||||
if (confSchema && confSchema.type !== "object") {
|
||||
newErrors.confSchema =
|
||||
'Config schema must have type "object" at root level';
|
||||
}
|
||||
|
||||
// Validate meta JSON
|
||||
if (meta.trim()) {
|
||||
try {
|
||||
JSON.parse(meta);
|
||||
} catch (e) {
|
||||
newErrors.meta = "Invalid JSON format";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedConfSchema =
|
||||
Object.keys(confSchema.properties || {}).length > 0 ? confSchema : {};
|
||||
const parsedMeta = meta.trim() ? JSON.parse(meta) : {};
|
||||
const tagsList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t);
|
||||
const runtimeDepsList = runtimeDeps
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d);
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
const updateData = {
|
||||
label: label.trim(),
|
||||
description: description.trim() || undefined,
|
||||
version: version.trim(),
|
||||
conf_schema: parsedConfSchema,
|
||||
config: configValues,
|
||||
meta: parsedMeta,
|
||||
tags: tagsList,
|
||||
runtime_deps: runtimeDepsList,
|
||||
is_standard: isStandard,
|
||||
};
|
||||
await updatePack.mutateAsync({ ref: pack!.ref, data: updateData });
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
const createData = {
|
||||
ref: ref.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || undefined,
|
||||
version: version.trim(),
|
||||
conf_schema: parsedConfSchema,
|
||||
config: configValues,
|
||||
meta: parsedMeta,
|
||||
tags: tagsList,
|
||||
runtime_deps: runtimeDepsList,
|
||||
is_standard: isStandard,
|
||||
};
|
||||
const newPackResponse = await createPack.mutateAsync(createData);
|
||||
const newPack = newPackResponse?.data;
|
||||
if (newPack?.ref) {
|
||||
navigate(`/packs/${newPack.ref}`);
|
||||
return;
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrors({
|
||||
submit:
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
"Failed to save pack",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
navigate("/packs");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setConfSchema(initialConfSchema);
|
||||
setConfigValues(initialConfig);
|
||||
};
|
||||
|
||||
const insertSchemaExample = (type: "api" | "database" | "webhook") => {
|
||||
let example;
|
||||
switch (type) {
|
||||
case "api":
|
||||
example = {
|
||||
type: "object",
|
||||
properties: {
|
||||
api_key: {
|
||||
type: "string",
|
||||
description: "API authentication key",
|
||||
},
|
||||
endpoint: {
|
||||
type: "string",
|
||||
description: "API endpoint URL",
|
||||
default: "https://api.example.com",
|
||||
},
|
||||
},
|
||||
required: ["api_key"],
|
||||
};
|
||||
break;
|
||||
|
||||
case "database":
|
||||
example = {
|
||||
type: "object",
|
||||
properties: {
|
||||
host: {
|
||||
type: "string",
|
||||
description: "Database host",
|
||||
default: "localhost",
|
||||
},
|
||||
port: {
|
||||
type: "integer",
|
||||
description: "Database port",
|
||||
default: 5432,
|
||||
},
|
||||
database: {
|
||||
type: "string",
|
||||
description: "Database name",
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
description: "Database username",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
description: "Database password",
|
||||
},
|
||||
},
|
||||
required: ["host", "database", "username", "password"],
|
||||
};
|
||||
break;
|
||||
|
||||
case "webhook":
|
||||
example = {
|
||||
type: "object",
|
||||
properties: {
|
||||
webhook_url: {
|
||||
type: "string",
|
||||
description: "Webhook destination URL",
|
||||
},
|
||||
auth_token: {
|
||||
type: "string",
|
||||
description: "Authentication token",
|
||||
},
|
||||
timeout: {
|
||||
type: "integer",
|
||||
description: "Request timeout in seconds",
|
||||
minimum: 1,
|
||||
maximum: 300,
|
||||
default: 30,
|
||||
},
|
||||
},
|
||||
required: ["webhook_url"],
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Update schema
|
||||
setConfSchema(example);
|
||||
|
||||
// Immediately sync config values with schema defaults
|
||||
const syncedConfig: Record<string, any> = {};
|
||||
if (example.properties) {
|
||||
Object.entries(example.properties).forEach(
|
||||
([key, propDef]: [string, any]) => {
|
||||
if (propDef.default !== undefined) {
|
||||
syncedConfig[key] = propDef.default;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
setConfigValues(syncedConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{errors.submit && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white shadow rounded-lg p-6 space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
{/* Label (display name) - MOVED FIRST */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="label"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Auto-populate ref from label if ref is empty and not editing
|
||||
if (!isEditing && !ref.trim() && label.trim()) {
|
||||
setRef(labelToRef(label));
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., My Custom Pack"
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.label ? "border-red-500" : "border-gray-300"
|
||||
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{errors.label && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Human-readable display name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ref (identifier) - MOVED AFTER LABEL */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ref"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Reference ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ref"
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
disabled={isEditing}
|
||||
placeholder="e.g., my_custom_pack"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.ref ? "border-red-500" : "border-gray-300"
|
||||
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{errors.ref && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Unique identifier. Lowercase letters, numbers, hyphens, and
|
||||
underscores only. Auto-populated from label.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this pack does..."
|
||||
rows={3}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="version"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Version <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="version"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder="1.0.0"
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.version ? "border-red-500" : "border-gray-300"
|
||||
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{errors.version && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.version}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Semantic version (e.g., 1.0.0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tags"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
placeholder="e.g., automation, cloud, monitoring"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Comma-separated tags for categorization
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Runtime Dependencies */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="runtimeDeps"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Runtime Dependencies
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="runtimeDeps"
|
||||
value={runtimeDeps}
|
||||
onChange={(e) => setRuntimeDeps(e.target.value)}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
placeholder="e.g., core, utils"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Comma-separated list of required pack refs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Standard Pack toggle */}
|
||||
{!isEditing && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isStandard"
|
||||
checked={isStandard}
|
||||
onChange={(e) => setIsStandard(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="isStandard"
|
||||
className="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Mark as standard pack (installed/deployed)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Schema */}
|
||||
<div className="bg-white shadow rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Configuration Schema
|
||||
</h3>
|
||||
{!isStandard && isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 px-3 py-1 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
title="Reset to database values"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isStandard && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500">Quick examples:</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertSchemaExample("api")}
|
||||
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
API Example
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertSchemaExample("database")}
|
||||
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Database Example
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertSchemaExample("webhook")}
|
||||
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Webhook Example
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SchemaBuilder
|
||||
label="Configuration Schema"
|
||||
value={confSchema}
|
||||
onChange={setConfSchema}
|
||||
error={errors.confSchema}
|
||||
disabled={isStandard}
|
||||
/>
|
||||
{isStandard ? (
|
||||
<div className="-mt-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Schema is locked for installed packs. Only configuration values
|
||||
can be edited.
|
||||
</p>
|
||||
{!hasSchemaProperties && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Note:</strong> This installed pack has no
|
||||
configuration schema. Configuration schemas for installed
|
||||
packs must be updated via pack installation/upgrade and cannot
|
||||
be edited through the web interface.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
Define the pack's configuration parameters that can be customized
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Configuration Values - Only show if schema has properties */}
|
||||
{hasSchemaProperties && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
Configuration Values
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Set values for the configuration parameters defined above
|
||||
</p>
|
||||
</div>
|
||||
<ParamSchemaForm
|
||||
schema={confSchema.properties}
|
||||
values={configValues}
|
||||
onChange={setConfigValues}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white shadow rounded-lg p-6 space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">
|
||||
Metadata
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="meta"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Metadata (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="meta"
|
||||
value={meta}
|
||||
onChange={(e) => setMeta(e.target.value)}
|
||||
rows={6}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-xs ${
|
||||
errors.meta ? "border-red-500" : "border-gray-300"
|
||||
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
placeholder='{"author": "Your Name", "homepage": "https://..."}'
|
||||
/>
|
||||
{errors.meta && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.meta}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Additional metadata for the pack (author, license, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createPack.isPending || updatePack.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{createPack.isPending || updatePack.isPending
|
||||
? "Saving..."
|
||||
: isEditing
|
||||
? "Update Pack"
|
||||
: "Create Pack"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user