feat: jira config in database

This commit is contained in:
Julien Froidefond
2025-09-17 18:04:13 +02:00
parent 83e48d5972
commit 0223611b3f
10 changed files with 383 additions and 50 deletions

View File

@@ -0,0 +1,46 @@
import { httpClient } from './base/http-client';
import { JiraConfig } from '@/lib/types';
export interface JiraConfigResponse {
jiraConfig: JiraConfig;
}
export interface SaveJiraConfigRequest {
baseUrl: string;
email: string;
apiToken: string;
}
export interface SaveJiraConfigResponse {
success: boolean;
message: string;
jiraConfig: JiraConfig;
}
class JiraConfigClient {
private readonly basePath = '/user-preferences/jira-config';
/**
* Récupère la configuration Jira actuelle
*/
async getJiraConfig(): Promise<JiraConfig> {
const response = await httpClient.get<JiraConfigResponse>(this.basePath);
return response.jiraConfig;
}
/**
* Sauvegarde la configuration Jira
*/
async saveJiraConfig(config: SaveJiraConfigRequest): Promise<SaveJiraConfigResponse> {
return httpClient.put<SaveJiraConfigResponse>(this.basePath, config);
}
/**
* Supprime la configuration Jira (remet à zéro)
*/
async deleteJiraConfig(): Promise<{ success: boolean; message: string }> {
return httpClient.delete(this.basePath);
}
}
export const jiraConfigClient = new JiraConfigClient();

View File

@@ -1,46 +1,100 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { AppConfig } from '@/lib/config';
import { useJiraConfig } from '@/hooks/useJiraConfig';
interface JiraConfigFormProps {
config: AppConfig;
}
export function JiraConfigForm({ config }: JiraConfigFormProps) {
export function JiraConfigForm() {
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
const [formData, setFormData] = useState({
baseUrl: '',
email: '',
apiToken: ''
});
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
// Charger les données existantes dans le formulaire
useEffect(() => {
if (config) {
setFormData({
baseUrl: config.baseUrl || '',
email: config.email || '',
apiToken: config.apiToken || ''
});
}
}, [config]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setIsSubmitting(true);
setMessage(null);
try {
// Note: Dans un vrai environnement, ces variables seraient configurées côté serveur
// Pour cette démo, on affiche juste un message informatif
setMessage({
type: 'success',
text: 'Configuration sauvegardée. Redémarrez l&apos;application pour appliquer les changements.'
});
} catch {
const result = await saveConfig(formData);
if (result.success) {
setMessage({
type: 'success',
text: result.message
});
} else {
setMessage({
type: 'error',
text: result.message
});
}
} catch (error) {
setMessage({
type: 'error',
text: 'Erreur lors de la sauvegarde de la configuration'
text: error instanceof Error ? error.message : 'Erreur lors de la sauvegarde de la configuration'
});
} finally {
setIsLoading(false);
setIsSubmitting(false);
}
};
const isJiraConfigured = config.integrations.jira.enabled;
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration Jira ?')) {
return;
}
setIsSubmitting(true);
setMessage(null);
try {
const result = await deleteConfig();
if (result.success) {
setFormData({
baseUrl: '',
email: '',
apiToken: ''
});
setMessage({
type: 'success',
text: result.message
});
} else {
setMessage({
type: 'error',
text: result.message
});
}
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Erreur lors de la suppression de la configuration'
});
} finally {
setIsSubmitting(false);
}
};
const isJiraConfigured = config?.enabled && (config?.baseUrl || config?.email);
const isLoading = configLoading || isSubmitting;
return (
<div className="space-y-6">
@@ -67,19 +121,19 @@ export function JiraConfigForm({ config }: JiraConfigFormProps) {
<div>
<span className="text-[var(--muted-foreground)]">URL de base:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config.integrations.jira.baseUrl || 'Non définie'}
{config?.baseUrl || 'Non définie'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Email:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config.integrations.jira.email || 'Non défini'}
{config?.email || 'Non défini'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Token API:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config.integrations.jira.apiToken ? '••••••••' : 'Non défini'}
{config?.apiToken ? '••••••••' : 'Non défini'}
</code>
</div>
</div>
@@ -147,13 +201,27 @@ export function JiraConfigForm({ config }: JiraConfigFormProps) {
</p>
</div>
<Button
type="submit"
disabled={isLoading}
className="w-full"
>
{isLoading ? 'Sauvegarde...' : 'Sauvegarder la configuration'}
</Button>
<div className="flex gap-3">
<Button
type="submit"
disabled={isLoading}
className="flex-1"
>
{isLoading ? 'Sauvegarde...' : 'Sauvegarder la configuration'}
</Button>
{isJiraConfigured && (
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={isLoading}
className="px-6"
>
Supprimer
</Button>
)}
</div>
</form>
{message && (

View File

@@ -6,13 +6,10 @@ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
import { JiraSync } from '@/components/jira/JiraSync';
import { JiraLogs } from '@/components/jira/JiraLogs';
import { AppConfig } from '@/lib/config';
import { useJiraConfig } from '@/hooks/useJiraConfig';
interface SettingsPageClientProps {
config: AppConfig;
}
export function SettingsPageClient({ config }: SettingsPageClientProps) {
export function SettingsPageClient() {
const { config: jiraConfig } = useJiraConfig();
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
const tabs = [
@@ -97,14 +94,14 @@ export function SettingsPageClient({ config }: SettingsPageClientProps) {
</p>
</CardHeader>
<CardContent>
<JiraConfigForm config={config} />
<JiraConfigForm />
</CardContent>
</Card>
</div>
{/* Colonne 2: Actions et Logs */}
<div className="space-y-4">
{config.integrations.jira.enabled && (
{jiraConfig?.enabled && (
<>
<JiraSync />
<JiraLogs />

80
hooks/useJiraConfig.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import { jiraConfigClient, SaveJiraConfigRequest } from '@/clients/jira-config-client';
import { JiraConfig } from '@/lib/types';
export function useJiraConfig() {
const [config, setConfig] = useState<JiraConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Charger la config au montage
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
setError(null);
const jiraConfig = await jiraConfigClient.getJiraConfig();
setConfig(jiraConfig);
} catch (err) {
console.error('Erreur lors du chargement de la config Jira:', err);
setError('Erreur lors du chargement de la configuration');
} finally {
setIsLoading(false);
}
};
const saveConfig = async (configData: SaveJiraConfigRequest) => {
try {
setError(null);
const response = await jiraConfigClient.saveJiraConfig(configData);
if (response.success) {
setConfig(response.jiraConfig);
return { success: true, message: response.message };
} else {
setError('Erreur lors de la sauvegarde');
return { success: false, message: 'Erreur lors de la sauvegarde' };
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de la sauvegarde de la configuration';
setError(errorMessage);
return { success: false, message: errorMessage };
}
};
const deleteConfig = async () => {
try {
setError(null);
const response = await jiraConfigClient.deleteJiraConfig();
if (response.success) {
setConfig({
baseUrl: '',
email: '',
apiToken: '',
enabled: false
});
return { success: true, message: response.message };
} else {
setError('Erreur lors de la suppression');
return { success: false, message: 'Erreur lors de la suppression' };
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de la suppression de la configuration';
setError(errorMessage);
return { success: false, message: errorMessage };
}
};
return {
config,
isLoading,
error,
saveConfig,
deleteConfig,
refetch: loadConfig
};
}

View File

@@ -81,6 +81,7 @@ export interface ColumnVisibility {
export interface JiraConfig {
baseUrl?: string;
email?: string;
apiToken?: string;
enabled: boolean;
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user_preferences" ADD COLUMN "jiraConfig" TEXT;

View File

@@ -98,6 +98,9 @@ model UserPreferences {
// Visibilité des colonnes (JSON)
columnVisibility Json?
// Configuration Jira (JSON)
jiraConfig Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -24,7 +24,10 @@ const DEFAULT_PREFERENCES: UserPreferences = {
hiddenStatuses: []
},
jiraConfig: {
enabled: false
enabled: false,
baseUrl: '',
email: '',
apiToken: ''
}
};
@@ -47,6 +50,7 @@ class UserPreferencesService {
kanbanFilters: DEFAULT_PREFERENCES.kanbanFilters,
viewPreferences: DEFAULT_PREFERENCES.viewPreferences,
columnVisibility: DEFAULT_PREFERENCES.columnVisibility,
jiraConfig: DEFAULT_PREFERENCES.jiraConfig as any,
}
});
@@ -165,15 +169,43 @@ class UserPreferencesService {
}
}
// === CONFIGURATION JIRA ===
/**
* Récupère la configuration Jira depuis les variables d'environnement
* Sauvegarde la configuration Jira
*/
async saveJiraConfig(config: JiraConfig): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { jiraConfig: config as any }
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde de la config Jira:', error);
throw error;
}
}
/**
* Récupère la configuration Jira depuis la base de données avec fallback sur les variables d'environnement
*/
async getJiraConfig(): Promise<JiraConfig> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const dbConfig = (userPrefs as any).jiraConfig as JiraConfig | null;
// Si config en DB, l'utiliser
if (dbConfig && (dbConfig.baseUrl || dbConfig.email || dbConfig.apiToken)) {
return { ...DEFAULT_PREFERENCES.jiraConfig, ...dbConfig };
}
// Sinon fallback sur les variables d'environnement (existant)
const config = getConfig();
return {
baseUrl: config.integrations.jira.baseUrl,
email: config.integrations.jira.email,
apiToken: '', // On ne retourne pas le token des env vars pour la sécurité
enabled: config.integrations.jira.enabled
};
} catch (error) {
@@ -202,14 +234,14 @@ class UserPreferencesService {
}
/**
* Sauvegarde toutes les préférences utilisateur (jiraConfig ignorée car elle vient des env vars)
* Sauvegarde toutes les préférences utilisateur
*/
async saveAllPreferences(preferences: UserPreferences): Promise<void> {
await Promise.all([
this.saveKanbanFilters(preferences.kanbanFilters),
this.saveViewPreferences(preferences.viewPreferences),
this.saveColumnVisibility(preferences.columnVisibility)
// jiraConfig n'est pas sauvegardée car elle vient des variables d'environnement
this.saveColumnVisibility(preferences.columnVisibility),
this.saveJiraConfig(preferences.jiraConfig)
]);
}
@@ -225,6 +257,7 @@ class UserPreferencesService {
kanbanFilters: DEFAULT_PREFERENCES.kanbanFilters,
viewPreferences: DEFAULT_PREFERENCES.viewPreferences,
columnVisibility: DEFAULT_PREFERENCES.columnVisibility,
jiraConfig: DEFAULT_PREFERENCES.jiraConfig as any,
}
});
} catch (error) {

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { userPreferencesService } from '@/services/user-preferences';
import { JiraConfig } from '@/lib/types';
/**
* GET /api/user-preferences/jira-config
* Récupère la configuration Jira
*/
export async function GET() {
try {
const jiraConfig = await userPreferencesService.getJiraConfig();
return NextResponse.json({ jiraConfig });
} catch (error) {
console.error('Erreur lors de la récupération de la config Jira:', error);
return NextResponse.json(
{ error: 'Erreur lors de la récupération de la configuration Jira' },
{ status: 500 }
);
}
}
/**
* PUT /api/user-preferences/jira-config
* Sauvegarde la configuration Jira
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { baseUrl, email, apiToken } = body;
// Validation des données requises
if (!baseUrl || !email || !apiToken) {
return NextResponse.json(
{ error: 'baseUrl, email et apiToken sont requis' },
{ status: 400 }
);
}
// Validation du format URL
try {
new URL(baseUrl);
} catch {
return NextResponse.json(
{ error: 'baseUrl doit être une URL valide' },
{ status: 400 }
);
}
// Validation du format email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'email doit être un email valide' },
{ status: 400 }
);
}
const jiraConfig: JiraConfig = {
baseUrl: baseUrl.trim(),
email: email.trim(),
apiToken: apiToken.trim(),
enabled: true
};
await userPreferencesService.saveJiraConfig(jiraConfig);
return NextResponse.json({
success: true,
message: 'Configuration Jira sauvegardée avec succès',
jiraConfig: {
...jiraConfig,
apiToken: '••••••••' // Masquer le token dans la réponse
}
});
} catch (error) {
console.error('Erreur lors de la sauvegarde de la config Jira:', error);
return NextResponse.json(
{ error: 'Erreur lors de la sauvegarde de la configuration Jira' },
{ status: 500 }
);
}
}
/**
* DELETE /api/user-preferences/jira-config
* Supprime la configuration Jira (remet à zéro)
*/
export async function DELETE() {
try {
const defaultConfig: JiraConfig = {
baseUrl: '',
email: '',
apiToken: '',
enabled: false
};
await userPreferencesService.saveJiraConfig(defaultConfig);
return NextResponse.json({
success: true,
message: 'Configuration Jira réinitialisée avec succès'
});
} catch (error) {
console.error('Erreur lors de la suppression de la config Jira:', error);
return NextResponse.json(
{ error: 'Erreur lors de la suppression de la configuration Jira' },
{ status: 500 }
);
}
}

View File

@@ -1,15 +1,8 @@
import { SettingsPageClient } from '@/components/settings/SettingsPageClient';
import { getConfig } from '@/lib/config';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function SettingsPage() {
const config = getConfig();
return (
<SettingsPageClient
config={config}
/>
);
return <SettingsPageClient />;
}