Files
towercontrol/src/components/settings/TfsConfigForm.tsx
Julien Froidefond 723a44df32 feat: TFS Sync
2025-09-22 21:51:12 +02:00

639 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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 &quot;New Token&quot;</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 vous êtes reviewer) dans
l&apos;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>
);
}