622 lines
21 KiB
TypeScript
622 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { GraphQLClient, gql } from "graphql-request";
|
|
import Image from "next/image";
|
|
import { useEffect, useState } from "react";
|
|
|
|
const GET_CONNECTORS_QUERY = gql`
|
|
query GetConnectors {
|
|
connectors {
|
|
success
|
|
message
|
|
connectors {
|
|
id
|
|
type
|
|
enabled
|
|
config
|
|
metadata {
|
|
id
|
|
target
|
|
platform
|
|
name
|
|
description
|
|
logo
|
|
logoDark
|
|
}
|
|
createdAt
|
|
updatedAt
|
|
}
|
|
availableConnectors {
|
|
id
|
|
target
|
|
platform
|
|
name
|
|
description
|
|
logo
|
|
logoDark
|
|
configTemplate
|
|
formItems {
|
|
key
|
|
type
|
|
label
|
|
placeholder
|
|
required
|
|
defaultValue
|
|
description
|
|
}
|
|
isAdded
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const CREATE_CONNECTOR_MUTATION = gql`
|
|
mutation CreateConnector($input: CreateConnectorInput!) {
|
|
createConnector(input: $input) {
|
|
success
|
|
message
|
|
connector {
|
|
id
|
|
type
|
|
enabled
|
|
metadata {
|
|
name
|
|
platform
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const UPDATE_CONNECTOR_MUTATION = gql`
|
|
mutation UpdateConnector($input: UpdateConnectorInput!) {
|
|
updateConnector(input: $input) {
|
|
success
|
|
message
|
|
connector {
|
|
id
|
|
enabled
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const DELETE_CONNECTOR_MUTATION = gql`
|
|
mutation DeleteConnector($id: ID!) {
|
|
deleteConnector(id: $id) {
|
|
success
|
|
message
|
|
}
|
|
}
|
|
`;
|
|
|
|
const TOGGLE_CONNECTOR_MUTATION = gql`
|
|
mutation ToggleConnector($id: ID!, $enabled: Boolean!) {
|
|
toggleConnector(id: $id, enabled: $enabled) {
|
|
success
|
|
message
|
|
connector {
|
|
id
|
|
enabled
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const TEST_CONNECTOR_MUTATION = gql`
|
|
mutation TestConnector($id: ID!) {
|
|
testConnector(id: $id) {
|
|
success
|
|
message
|
|
}
|
|
}
|
|
`;
|
|
|
|
interface Connector {
|
|
id: string;
|
|
type: string;
|
|
enabled: boolean;
|
|
config: Record<string, unknown>;
|
|
metadata: {
|
|
id: string;
|
|
target: string;
|
|
platform?: string;
|
|
name: string | Record<string, string>;
|
|
description: string | Record<string, string>;
|
|
logo: string;
|
|
logoDark: string | null;
|
|
};
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface FactoryConnector {
|
|
id: string;
|
|
target: string;
|
|
platform?: string;
|
|
name: string | Record<string, string>;
|
|
description: string | Record<string, string>;
|
|
logo: string;
|
|
logoDark: string | null;
|
|
configTemplate: Record<string, unknown> | null;
|
|
formItems: FormItem[];
|
|
isAdded: boolean;
|
|
}
|
|
|
|
interface FormItem {
|
|
key: string;
|
|
type: string;
|
|
label: string | Record<string, string>;
|
|
placeholder?: string | Record<string, string>;
|
|
required?: boolean;
|
|
defaultValue?: string | boolean | number | string[];
|
|
description?: string | Record<string, string>;
|
|
}
|
|
|
|
interface ConnectorsResponse {
|
|
connectors: {
|
|
success: boolean;
|
|
message?: string;
|
|
connectors?: Connector[];
|
|
availableConnectors?: FactoryConnector[];
|
|
};
|
|
}
|
|
|
|
export default function ConnectorsPage() {
|
|
const [connectors, setConnectors] = useState<Connector[]>([]);
|
|
const [availableConnectors, setAvailableConnectors] = useState<FactoryConnector[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedFactory, setSelectedFactory] = useState<FactoryConnector | null>(null);
|
|
const [showConfig, setShowConfig] = useState(false);
|
|
const [configForm, setConfigForm] = useState<Record<string, string | boolean | number | string[]>>({});
|
|
|
|
useEffect(() => {
|
|
fetchConnectors();
|
|
}, []);
|
|
|
|
const fetchConnectors = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const storedToken = localStorage.getItem("accessToken");
|
|
if (!storedToken) {
|
|
setError("Aucun token trouvé. Veuillez vous connecter.");
|
|
return;
|
|
}
|
|
|
|
const authenticatedClient = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql", {
|
|
headers: {
|
|
Authorization: `Bearer ${storedToken}`,
|
|
},
|
|
});
|
|
|
|
const response = await authenticatedClient.request<ConnectorsResponse>(GET_CONNECTORS_QUERY);
|
|
|
|
if (response.connectors.success) {
|
|
setConnectors(response.connectors.connectors || []);
|
|
setAvailableConnectors(response.connectors.availableConnectors || []);
|
|
} else {
|
|
setError(response.connectors.message || "Erreur lors du chargement");
|
|
}
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : "Une erreur est survenue";
|
|
setError(errorMessage);
|
|
console.error("Erreur lors du chargement des connecteurs:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateConnector = async () => {
|
|
if (!selectedFactory) return;
|
|
|
|
try {
|
|
const storedToken = localStorage.getItem("accessToken");
|
|
if (!storedToken) {
|
|
setError("Aucun token trouvé. Veuillez vous connecter.");
|
|
return;
|
|
}
|
|
|
|
const authenticatedClient = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql", {
|
|
headers: {
|
|
Authorization: `Bearer ${storedToken}`,
|
|
},
|
|
});
|
|
|
|
const response = (await authenticatedClient.request(CREATE_CONNECTOR_MUTATION, {
|
|
input: {
|
|
connectorId: selectedFactory.id,
|
|
config: configForm,
|
|
},
|
|
})) as {
|
|
createConnector: {
|
|
success: boolean;
|
|
message?: string;
|
|
connector?: {
|
|
id: string;
|
|
enabled: boolean;
|
|
metadata: {
|
|
name: string | Record<string, string>;
|
|
platform?: string;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
if (response.createConnector.success) {
|
|
await fetchConnectors();
|
|
setShowConfig(false);
|
|
setSelectedFactory(null);
|
|
setConfigForm({});
|
|
} else {
|
|
setError(response.createConnector.message || "Erreur lors de la création");
|
|
}
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : "Une erreur est survenue";
|
|
setError(errorMessage);
|
|
}
|
|
};
|
|
|
|
const handleToggleConnector = async (id: string, enabled: boolean) => {
|
|
try {
|
|
const storedToken = localStorage.getItem("accessToken");
|
|
if (!storedToken) return;
|
|
|
|
const authenticatedClient = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql", {
|
|
headers: {
|
|
Authorization: `Bearer ${storedToken}`,
|
|
},
|
|
});
|
|
|
|
await authenticatedClient.request(TOGGLE_CONNECTOR_MUTATION, {
|
|
id,
|
|
enabled,
|
|
});
|
|
|
|
await fetchConnectors();
|
|
} catch (err: unknown) {
|
|
console.error("Erreur lors du basculement:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteConnector = async (id: string) => {
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce connecteur ?")) return;
|
|
|
|
try {
|
|
const storedToken = localStorage.getItem("accessToken");
|
|
if (!storedToken) return;
|
|
|
|
const authenticatedClient = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql", {
|
|
headers: {
|
|
Authorization: `Bearer ${storedToken}`,
|
|
},
|
|
});
|
|
|
|
await authenticatedClient.request(DELETE_CONNECTOR_MUTATION, { id });
|
|
await fetchConnectors();
|
|
} catch (err: unknown) {
|
|
console.error("Erreur lors de la suppression:", err);
|
|
}
|
|
};
|
|
|
|
const handleTestConnector = async (id: string) => {
|
|
try {
|
|
const storedToken = localStorage.getItem("accessToken");
|
|
if (!storedToken) return;
|
|
|
|
const authenticatedClient = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql", {
|
|
headers: {
|
|
Authorization: `Bearer ${storedToken}`,
|
|
},
|
|
});
|
|
|
|
const response = (await authenticatedClient.request(TEST_CONNECTOR_MUTATION, { id })) as {
|
|
testConnector: {
|
|
success: boolean;
|
|
message?: string;
|
|
};
|
|
};
|
|
alert(response.testConnector.message || "Test terminé");
|
|
} catch (err: unknown) {
|
|
console.error("Erreur lors du test:", err);
|
|
}
|
|
};
|
|
|
|
const openConfigDialog = (factory: FactoryConnector) => {
|
|
setSelectedFactory(factory);
|
|
setShowConfig(true);
|
|
// Initialiser le formulaire avec les valeurs par défaut
|
|
const initialForm: Record<string, string | boolean | number | string[]> = {};
|
|
factory.formItems.forEach(item => {
|
|
if (item.defaultValue !== undefined) {
|
|
initialForm[item.key] = item.defaultValue;
|
|
}
|
|
});
|
|
setConfigForm(initialForm);
|
|
};
|
|
|
|
const getConnectorName = (nameObj: string | Record<string, string>): string => {
|
|
if (typeof nameObj === "object" && nameObj !== null) {
|
|
return nameObj.en || nameObj.fr || Object.values(nameObj)[0] || "Connecteur";
|
|
}
|
|
return nameObj || "Connecteur";
|
|
};
|
|
|
|
const getConnectorDescription = (descObj: string | Record<string, string>): string => {
|
|
if (typeof descObj === "object" && descObj !== null) {
|
|
return descObj.en || descObj.fr || Object.values(descObj)[0] || "";
|
|
}
|
|
return descObj || "";
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
|
<div className="mx-auto max-w-6xl">
|
|
<div className="rounded-lg bg-white p-8 shadow-lg">
|
|
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">Gestion des Connecteurs Logto</h1>
|
|
|
|
{error && (
|
|
<div className="mb-6 rounded-md border border-red-200 bg-red-50 p-4">
|
|
<p className="text-sm text-red-700">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="text-center">
|
|
<p>Chargement des connecteurs...</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-8">
|
|
{/* Connecteurs configurés */}
|
|
<div>
|
|
<h2 className="mb-4 text-xl font-semibold text-gray-800">
|
|
Connecteurs configurés ({connectors.length})
|
|
</h2>
|
|
|
|
{connectors.length === 0 ? (
|
|
<p className="text-gray-500">Aucun connecteur configuré</p>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{connectors.map(connector => (
|
|
<div key={connector.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
<div className="flex items-center justify-between">
|
|
<Image
|
|
src={connector.metadata.logo}
|
|
alt={getConnectorName(connector.metadata.name)}
|
|
width={32}
|
|
height={32}
|
|
className="h-8 w-8"
|
|
/>
|
|
<span
|
|
className={`rounded-full px-2 py-1 text-xs ${
|
|
connector.enabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
|
}`}
|
|
>
|
|
{connector.enabled ? "Activé" : "Désactivé"}
|
|
</span>
|
|
</div>
|
|
|
|
<h3 className="mt-2 font-medium text-gray-900">{getConnectorName(connector.metadata.name)}</h3>
|
|
|
|
<p className="text-sm text-gray-600">{connector.metadata.platform || connector.type}</p>
|
|
|
|
<div className="mt-4 flex space-x-2">
|
|
<button
|
|
onClick={() => handleToggleConnector(connector.id, !connector.enabled)}
|
|
className={`rounded px-3 py-1 text-xs ${
|
|
connector.enabled
|
|
? "bg-red-100 text-red-700 hover:bg-red-200"
|
|
: "bg-green-100 text-green-700 hover:bg-green-200"
|
|
}`}
|
|
>
|
|
{connector.enabled ? "Désactiver" : "Activer"}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleTestConnector(connector.id)}
|
|
className="rounded bg-blue-100 px-3 py-1 text-xs text-blue-700 hover:bg-blue-200"
|
|
>
|
|
Tester
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleDeleteConnector(connector.id)}
|
|
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Connecteurs disponibles */}
|
|
<div>
|
|
<h2 className="mb-4 text-xl font-semibold text-gray-800">Connecteurs disponibles</h2>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{availableConnectors.map(factory => (
|
|
<div
|
|
key={factory.id}
|
|
className={`rounded-lg border p-4 shadow-sm ${
|
|
factory.isAdded ? "border-gray-300 bg-gray-50" : "border-gray-200 bg-white"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<Image
|
|
src={factory.logo}
|
|
alt={getConnectorName(factory.name)}
|
|
width={32}
|
|
height={32}
|
|
className="h-8 w-8"
|
|
/>
|
|
{factory.isAdded && (
|
|
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">Ajouté</span>
|
|
)}
|
|
</div>
|
|
|
|
<h3 className="mt-2 font-medium text-gray-900">{getConnectorName(factory.name)}</h3>
|
|
|
|
<p className="text-sm text-gray-600">{getConnectorDescription(factory.description)}</p>
|
|
|
|
{!factory.isAdded && (
|
|
<button
|
|
onClick={() => openConfigDialog(factory)}
|
|
className="mt-4 w-full rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
|
>
|
|
Configurer
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dialog de configuration */}
|
|
{showConfig && selectedFactory && (
|
|
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
<div className="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-lg bg-white p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className="text-lg font-medium">Configurer {getConnectorName(selectedFactory.name)}</h3>
|
|
<button onClick={() => setShowConfig(false)} className="text-gray-400 hover:text-gray-600">
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={e => {
|
|
e.preventDefault();
|
|
handleCreateConnector();
|
|
}}
|
|
className="space-y-4"
|
|
>
|
|
{selectedFactory.formItems.map(item => (
|
|
<div key={item.key}>
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
{typeof item.label === "object" && item.label !== null
|
|
? item.label.en || item.label.fr
|
|
: item.label}
|
|
{item.required && <span className="text-red-500">*</span>}
|
|
</label>
|
|
|
|
{item.type === "Password" ? (
|
|
<input
|
|
type="password"
|
|
required={item.required}
|
|
value={String(configForm[item.key] || "")}
|
|
onChange={e => setConfigForm({ ...configForm, [item.key]: e.target.value })}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
/>
|
|
) : item.type === "Switch" ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(configForm[item.key])}
|
|
onChange={e => setConfigForm({ ...configForm, [item.key]: e.target.checked })}
|
|
className="mt-1"
|
|
/>
|
|
) : item.type === "MultiSelect" ? (
|
|
<select
|
|
multiple
|
|
required={item.required}
|
|
value={Array.isArray(configForm[item.key]) ? (configForm[item.key] as string[]) : []}
|
|
onChange={e => {
|
|
const values = Array.from(e.target.selectedOptions, option => option.value);
|
|
setConfigForm({ ...configForm, [item.key]: values });
|
|
}}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
>
|
|
{/* Options would come from the form item configuration */}
|
|
</select>
|
|
) : item.type === "Number" ? (
|
|
<input
|
|
type="number"
|
|
required={item.required}
|
|
value={Number(configForm[item.key] || 0)}
|
|
onChange={e => setConfigForm({ ...configForm, [item.key]: Number(e.target.value) })}
|
|
placeholder={
|
|
typeof item.placeholder === "object" && item.placeholder !== null
|
|
? item.placeholder.en || item.placeholder.fr
|
|
: item.placeholder || ""
|
|
}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
required={item.required}
|
|
value={String(configForm[item.key] || "")}
|
|
onChange={e => setConfigForm({ ...configForm, [item.key]: e.target.value })}
|
|
placeholder={
|
|
typeof item.placeholder === "object" && item.placeholder !== null
|
|
? item.placeholder.en || item.placeholder.fr
|
|
: item.placeholder || ""
|
|
}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
/>
|
|
)}
|
|
|
|
{item.description && (
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{typeof item.description === "object" && item.description !== null
|
|
? item.description.en || item.description.fr
|
|
: item.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex space-x-4 pt-4">
|
|
<button type="submit" className="flex-1 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
|
|
Créer
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConfig(false)}
|
|
className="flex-1 rounded border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation */}
|
|
<div className="mt-8 rounded-md border border-blue-200 bg-blue-50 p-4">
|
|
<h3 className="text-sm font-medium text-blue-800">Navigation</h3>
|
|
<div className="mt-2 space-y-1">
|
|
<div>
|
|
<a href="/login" className="text-sm text-blue-600 underline hover:text-blue-500">
|
|
🔐 Page de connexion
|
|
</a>
|
|
</div>
|
|
<div>
|
|
<a href="/signup" className="text-sm text-blue-600 underline hover:text-blue-500">
|
|
📝 Page d'inscription
|
|
</a>
|
|
</div>
|
|
<div>
|
|
<a href="/profile" className="text-sm text-blue-600 underline hover:text-blue-500">
|
|
👤 Voir votre profil
|
|
</a>
|
|
</div>
|
|
<div>
|
|
<a href="/test-users" className="text-sm text-blue-600 underline hover:text-blue-500">
|
|
👥 Liste des utilisateurs
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|