639 lines
23 KiB
TypeScript
639 lines
23 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useTransition } from 'react';
|
||
import { TfsConfig } from '@/services/tfs';
|
||
import { getTfsConfig, saveTfsConfig, deleteAllTfsTasks } from '@/actions/tfs';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Badge } from '@/components/ui/Badge';
|
||
|
||
export function TfsConfigForm() {
|
||
const [config, setConfig] = useState<TfsConfig>({
|
||
enabled: false,
|
||
organizationUrl: '',
|
||
projectName: '',
|
||
personalAccessToken: '',
|
||
repositories: [],
|
||
ignoredRepositories: [],
|
||
});
|
||
const [isPending, startTransition] = useTransition();
|
||
const [message, setMessage] = useState<{
|
||
type: 'success' | 'error';
|
||
text: string;
|
||
} | null>(null);
|
||
const [testingConnection, setTestingConnection] = useState(false);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [deletingTasks, setDeletingTasks] = useState(false);
|
||
|
||
// Charger la configuration existante
|
||
useEffect(() => {
|
||
loadConfig();
|
||
}, []);
|
||
|
||
const loadConfig = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const result = await getTfsConfig();
|
||
if (result.success) {
|
||
setConfig(result.data);
|
||
// Afficher le formulaire par défaut si TFS n'est pas configuré
|
||
const isConfigured =
|
||
result.data?.enabled &&
|
||
result.data?.organizationUrl &&
|
||
result.data?.personalAccessToken;
|
||
if (!isConfigured) {
|
||
setShowForm(true);
|
||
}
|
||
} else {
|
||
setMessage({
|
||
type: 'error',
|
||
text: result.error || 'Erreur lors du chargement de la configuration',
|
||
});
|
||
setShowForm(true); // Afficher le formulaire en cas d'erreur
|
||
}
|
||
} catch (error) {
|
||
console.error('Erreur chargement config TFS:', error);
|
||
setMessage({
|
||
type: 'error',
|
||
text: 'Erreur lors du chargement de la configuration',
|
||
});
|
||
setShowForm(true);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveConfig = () => {
|
||
startTransition(async () => {
|
||
setMessage(null);
|
||
const result = await saveTfsConfig(config);
|
||
|
||
if (result.success) {
|
||
setMessage({
|
||
type: 'success',
|
||
text: result.message || 'Configuration sauvegardée',
|
||
});
|
||
// Masquer le formulaire après une sauvegarde réussie
|
||
setShowForm(false);
|
||
} else {
|
||
setMessage({
|
||
type: 'error',
|
||
text: result.error || 'Erreur lors de la sauvegarde',
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration TFS ?')) {
|
||
return;
|
||
}
|
||
|
||
startTransition(async () => {
|
||
setMessage(null);
|
||
// Réinitialiser la config
|
||
const resetConfig = {
|
||
enabled: false,
|
||
organizationUrl: '',
|
||
projectName: '',
|
||
personalAccessToken: '',
|
||
repositories: [],
|
||
ignoredRepositories: [],
|
||
};
|
||
|
||
const result = await saveTfsConfig(resetConfig);
|
||
|
||
if (result.success) {
|
||
setConfig(resetConfig);
|
||
setMessage({ type: 'success', text: 'Configuration TFS supprimée' });
|
||
setShowForm(true); // Afficher le formulaire pour reconfigurer
|
||
} else {
|
||
setMessage({
|
||
type: 'error',
|
||
text: result.error || 'Erreur lors de la suppression',
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const testConnection = async () => {
|
||
try {
|
||
setTestingConnection(true);
|
||
setMessage(null);
|
||
|
||
// Sauvegarder d'abord la config
|
||
const saveResult = await saveTfsConfig(config);
|
||
if (!saveResult.success) {
|
||
setMessage({
|
||
type: 'error',
|
||
text: saveResult.error || 'Erreur lors de la sauvegarde',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Attendre un peu que la configuration soit prise en compte
|
||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
|
||
// Tester la connexion avec la route dédiée
|
||
const response = await fetch('/api/tfs/test', {
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
const result = await response.json();
|
||
console.log('Test TFS - Réponse:', { status: response.status, result });
|
||
|
||
if (response.ok && result.connected) {
|
||
setMessage({
|
||
type: 'success',
|
||
text: `Connexion Azure DevOps réussie ! ${result.message || ''}`,
|
||
});
|
||
} else {
|
||
const errorMessage =
|
||
result.error || result.details || 'Erreur de connexion inconnue';
|
||
setMessage({
|
||
type: 'error',
|
||
text: `Connexion échouée: ${errorMessage}`,
|
||
});
|
||
console.error('Test TFS échoué:', result);
|
||
}
|
||
} catch (error) {
|
||
console.error('Erreur test connexion TFS:', error);
|
||
setMessage({
|
||
type: 'error',
|
||
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||
});
|
||
} finally {
|
||
setTestingConnection(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteAllTasks = async () => {
|
||
const confirmation = confirm(
|
||
'Êtes-vous sûr de vouloir supprimer TOUTES les tâches TFS de la base locale ?\n\n' +
|
||
'Cette action est irréversible et supprimera définitivement toutes les tâches ' +
|
||
'synchronisées depuis Azure DevOps/TFS.\n\n' +
|
||
'Cliquez sur OK pour confirmer la suppression.'
|
||
);
|
||
|
||
if (!confirmation) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setDeletingTasks(true);
|
||
setMessage(null);
|
||
|
||
const result = await deleteAllTfsTasks();
|
||
|
||
if (result.success) {
|
||
setMessage({
|
||
type: 'success',
|
||
text:
|
||
result.message ||
|
||
'Toutes les tâches TFS ont été supprimées avec succès',
|
||
});
|
||
} else {
|
||
setMessage({
|
||
type: 'error',
|
||
text: result.error || 'Erreur lors de la suppression des tâches TFS',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Erreur suppression tâches TFS:', error);
|
||
setMessage({
|
||
type: 'error',
|
||
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
|
||
});
|
||
} finally {
|
||
setDeletingTasks(false);
|
||
}
|
||
};
|
||
|
||
const updateConfig = (
|
||
field: keyof TfsConfig,
|
||
value: string | boolean | string[]
|
||
) => {
|
||
setConfig((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const updateArrayField = (
|
||
field: 'repositories' | 'ignoredRepositories',
|
||
value: string
|
||
) => {
|
||
const array = value
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter((item) => item);
|
||
updateConfig(field, array);
|
||
};
|
||
|
||
const isTfsConfigured =
|
||
config?.enabled && config?.organizationUrl && config?.personalAccessToken;
|
||
const isLoadingState = isLoading || isPending || deletingTasks;
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="text-sm text-[var(--muted-foreground)]">
|
||
Chargement...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Statut actuel */}
|
||
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
|
||
<div>
|
||
<h3 className="font-medium">Statut de l'intégration</h3>
|
||
<p className="text-sm text-[var(--muted-foreground)]">
|
||
{isTfsConfigured
|
||
? 'Azure DevOps est configuré et prêt à être utilisé'
|
||
: "Azure DevOps n'est pas configuré"}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant={isTfsConfigured ? 'success' : 'danger'}>
|
||
{isTfsConfigured ? '✓ Configuré' : '✗ Non configuré'}
|
||
</Badge>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setShowForm(!showForm)}
|
||
>
|
||
{showForm ? 'Masquer' : isTfsConfigured ? 'Modifier' : 'Configurer'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{isTfsConfigured && (
|
||
<div className="p-4 bg-[var(--card)] rounded border">
|
||
<h3 className="font-medium mb-2">Configuration actuelle</h3>
|
||
<div className="space-y-2 text-sm">
|
||
<div>
|
||
<span className="text-[var(--muted-foreground)]">
|
||
URL d'organisation:
|
||
</span>{' '}
|
||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||
{config?.organizationUrl || 'Non définie'}
|
||
</code>
|
||
</div>
|
||
<div>
|
||
<span className="text-[var(--muted-foreground)]">Projet:</span>{' '}
|
||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||
{config?.projectName || "Toute l'organisation"}
|
||
</code>
|
||
</div>
|
||
<div>
|
||
<span className="text-[var(--muted-foreground)]">Token PAT:</span>{' '}
|
||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||
{config?.personalAccessToken ? '••••••••' : 'Non défini'}
|
||
</code>
|
||
</div>
|
||
<div>
|
||
<span className="text-[var(--muted-foreground)]">
|
||
Repositories surveillés:
|
||
</span>{' '}
|
||
{config?.repositories && config.repositories.length > 0 ? (
|
||
<div className="mt-1 space-x-1">
|
||
{config.repositories.map((repo) => (
|
||
<code
|
||
key={repo}
|
||
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
|
||
>
|
||
{repo}
|
||
</code>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-xs">Tous les repositories</span>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<span className="text-[var(--muted-foreground)]">
|
||
Repositories ignorés:
|
||
</span>{' '}
|
||
{config?.ignoredRepositories &&
|
||
config.ignoredRepositories.length > 0 ? (
|
||
<div className="mt-1 space-x-1">
|
||
{config.ignoredRepositories.map((repo) => (
|
||
<code
|
||
key={repo}
|
||
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
|
||
>
|
||
{repo}
|
||
</code>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-xs">Aucun</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions de gestion des données TFS */}
|
||
{isTfsConfigured && (
|
||
<div className="p-4 bg-[var(--card)] rounded border border-orange-200 dark:border-orange-800">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="font-medium text-orange-800 dark:text-orange-200">
|
||
⚠️ Gestion des données
|
||
</h3>
|
||
<p className="text-sm text-orange-600 dark:text-orange-300">
|
||
Supprimez toutes les tâches TFS synchronisées de la base locale
|
||
</p>
|
||
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1">
|
||
<strong>Attention:</strong> Cette action est irréversible et
|
||
supprimera définitivement toutes les tâches importées depuis
|
||
Azure DevOps.
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="danger"
|
||
onClick={handleDeleteAllTasks}
|
||
disabled={deletingTasks}
|
||
className="px-6"
|
||
>
|
||
{deletingTasks
|
||
? 'Suppression...'
|
||
: '🗑️ Supprimer toutes les tâches TFS'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Formulaire de configuration */}
|
||
{showForm && (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
handleSaveConfig();
|
||
}}
|
||
className="space-y-4"
|
||
>
|
||
{/* Toggle d'activation */}
|
||
<div className="flex items-center justify-between p-4 bg-[var(--muted)] rounded-lg">
|
||
<div>
|
||
<h4 className="font-medium">Activer l'intégration TFS</h4>
|
||
<p className="text-sm text-[var(--muted-foreground)]">
|
||
Synchroniser les Pull Requests depuis Azure DevOps
|
||
</p>
|
||
</div>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={config.enabled}
|
||
onChange={(e) => updateConfig('enabled', e.target.checked)}
|
||
className="sr-only peer"
|
||
/>
|
||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||
</label>
|
||
</div>
|
||
|
||
{config.enabled && (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">
|
||
URL de l'organisation Azure DevOps
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={config.organizationUrl || ''}
|
||
onChange={(e) =>
|
||
updateConfig('organizationUrl', e.target.value)
|
||
}
|
||
placeholder="https://dev.azure.com/votre-organisation"
|
||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||
required
|
||
/>
|
||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||
L'URL de base de votre organisation Azure DevOps (ex:
|
||
https://dev.azure.com/monentreprise)
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">
|
||
Nom du projet (optionnel)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={config.projectName || ''}
|
||
onChange={(e) => updateConfig('projectName', e.target.value)}
|
||
placeholder="MonProjet (laisser vide pour toute l'organisation)"
|
||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||
Nom du projet spécifique ou laisser vide pour synchroniser les
|
||
PRs de toute l'organisation
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">
|
||
Personal Access Token (PAT)
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={config.personalAccessToken || ''}
|
||
onChange={(e) =>
|
||
updateConfig('personalAccessToken', e.target.value)
|
||
}
|
||
placeholder="Votre token d'accès personnel"
|
||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||
required
|
||
/>
|
||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||
Créez un PAT depuis{' '}
|
||
<a
|
||
href="https://dev.azure.com/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-[var(--primary)] hover:underline"
|
||
>
|
||
Azure DevOps
|
||
</a>{' '}
|
||
avec les permissions Code (read) et Pull Request (read)
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">
|
||
Repositories à surveiller (optionnel)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={config.repositories?.join(', ') || ''}
|
||
onChange={(e) =>
|
||
updateArrayField('repositories', e.target.value)
|
||
}
|
||
placeholder="repo1, repo2, repo3"
|
||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||
Liste séparée par des virgules. Laisser vide pour surveiller
|
||
tous les repositories.
|
||
</p>
|
||
{config.repositories && config.repositories.length > 0 && (
|
||
<div className="mt-2 space-x-1">
|
||
<span className="text-xs text-[var(--muted-foreground)]">
|
||
Repositories surveillés:
|
||
</span>
|
||
{config.repositories.map((repo) => (
|
||
<code
|
||
key={repo}
|
||
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
|
||
>
|
||
{repo}
|
||
</code>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-2">
|
||
Repositories à ignorer (optionnel)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={config.ignoredRepositories?.join(', ') || ''}
|
||
onChange={(e) =>
|
||
updateArrayField('ignoredRepositories', e.target.value)
|
||
}
|
||
placeholder="test-repo, demo-repo"
|
||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||
Repositories à exclure de la synchronisation, séparés par des
|
||
virgules (ex: test-repo, demo-repo).
|
||
</p>
|
||
{config.ignoredRepositories &&
|
||
config.ignoredRepositories.length > 0 && (
|
||
<div className="mt-2 space-x-1">
|
||
<span className="text-xs text-[var(--muted-foreground)]">
|
||
Repositories ignorés:
|
||
</span>
|
||
{config.ignoredRepositories.map((repo) => (
|
||
<code
|
||
key={repo}
|
||
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
|
||
>
|
||
{repo}
|
||
</code>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex gap-3">
|
||
<Button type="submit" disabled={isLoadingState} className="flex-1">
|
||
{isLoadingState
|
||
? 'Sauvegarde...'
|
||
: 'Sauvegarder la configuration'}
|
||
</Button>
|
||
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={testConnection}
|
||
disabled={
|
||
testingConnection ||
|
||
!config.organizationUrl ||
|
||
!config.personalAccessToken
|
||
}
|
||
className="px-6"
|
||
>
|
||
{testingConnection ? 'Test...' : 'Tester'}
|
||
</Button>
|
||
|
||
{isTfsConfigured && (
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={handleDelete}
|
||
disabled={isLoadingState}
|
||
className="px-6"
|
||
>
|
||
Supprimer
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Instructions */}
|
||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||
<h3 className="font-medium mb-2">
|
||
💡 Instructions de configuration
|
||
</h3>
|
||
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
|
||
<p>
|
||
<strong>1. URL d'organisation:</strong> Votre domaine Azure
|
||
DevOps (ex: https://dev.azure.com/monentreprise)
|
||
</p>
|
||
<p>
|
||
<strong>2. Nom du projet (optionnel):</strong> Spécifiez un
|
||
projet pour limiter la synchronisation, ou laissez vide pour
|
||
toute l'organisation
|
||
</p>
|
||
<p>
|
||
<strong>3. Personal Access Token:</strong> Créez un PAT depuis
|
||
Azure DevOps :
|
||
</p>
|
||
<ul className="ml-4 space-y-1 list-disc">
|
||
<li>
|
||
Allez sur{' '}
|
||
<a
|
||
href="https://dev.azure.com/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-[var(--primary)] hover:underline"
|
||
>
|
||
dev.azure.com
|
||
</a>
|
||
</li>
|
||
<li>Cliquez sur votre profil » Personal access tokens</li>
|
||
<li>Cliquez sur "New Token"</li>
|
||
<li>
|
||
Sélectionnez les scopes: Code (read) et Pull Request (read)
|
||
</li>
|
||
<li>Copiez le token généré</li>
|
||
</ul>
|
||
<p className="mt-3 text-xs">
|
||
<strong>🎯 Synchronisation intelligente:</strong> TowerControl
|
||
récupère automatiquement toutes les Pull Requests vous
|
||
concernant (créées par vous ou où vous êtes reviewer) dans
|
||
l'organisation ou le projet configuré.
|
||
</p>
|
||
<p className="text-xs">
|
||
<strong>Note:</strong> Les PRs seront synchronisées comme tâches
|
||
pour un suivi centralisé de vos activités.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{message && (
|
||
<div
|
||
className={`p-4 rounded border ${
|
||
message.type === 'success'
|
||
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||
}`}
|
||
>
|
||
{message.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|