1117 lines
40 KiB
TypeScript
1117 lines
40 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import {
|
|
User,
|
|
Mail,
|
|
Phone,
|
|
MapPin,
|
|
Calendar,
|
|
Globe,
|
|
Save,
|
|
Edit,
|
|
Trash2,
|
|
Eye,
|
|
EyeOff,
|
|
Shield,
|
|
Key,
|
|
Bell,
|
|
Settings as SettingsIcon,
|
|
Download,
|
|
Upload,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
XCircle,
|
|
Info,
|
|
Lock,
|
|
Unlock,
|
|
UserCheck,
|
|
Users,
|
|
Smartphone,
|
|
Plus,
|
|
X,
|
|
} from "@/lib/icons"
|
|
|
|
export default function SettingsPage() {
|
|
const [activeTab, setActiveTab] = useState("profile")
|
|
const [showAddDeviceModal, setShowAddDeviceModal] = useState(false)
|
|
const [showExportConfirmation, setShowExportConfirmation] = useState(false)
|
|
const [settings, setSettings] = useState({
|
|
profile: {
|
|
firstName: "Utilisateur",
|
|
lastName: "Démo",
|
|
email: "demo@docv.fr",
|
|
phone: "+33 1 23 45 67 89",
|
|
position: "Administrateur",
|
|
department: "Direction",
|
|
bio: "Utilisateur de démonstration pour DocV",
|
|
},
|
|
security: {
|
|
sessionTimeout: "30",
|
|
passwordLastChanged: new Date("2024-01-01"),
|
|
devices: [
|
|
{ id: "current", label: "Appareil actuel", addedAt: new Date().toISOString(), ratio: 100 },
|
|
],
|
|
},
|
|
notifications: {
|
|
emailNotifications: true,
|
|
pushNotifications: true,
|
|
documentUpdates: true,
|
|
folderSharing: true,
|
|
systemAlerts: true,
|
|
weeklyReport: false,
|
|
},
|
|
appearance: {
|
|
theme: "light",
|
|
language: "fr",
|
|
timezone: "Europe/Paris",
|
|
dateFormat: "dd/mm/yyyy",
|
|
compactMode: false,
|
|
},
|
|
privacy: {
|
|
profileVisibility: "team",
|
|
activityTracking: true,
|
|
dataSharing: false,
|
|
analyticsOptIn: true,
|
|
},
|
|
storage: {
|
|
used: 67.3,
|
|
total: 100,
|
|
autoBackup: true,
|
|
retentionPeriod: "365",
|
|
},
|
|
})
|
|
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [notification, setNotification] = useState<{ type: "success" | "error" | "info"; message: string } | null>(null)
|
|
const [showPairingWords, setShowPairingWords] = useState(false)
|
|
const [isSyncing, setIsSyncing] = useState(false)
|
|
const [syncProgress, setSyncProgress] = useState(0)
|
|
const [isImporting, setIsImporting] = useState(false)
|
|
const [isDarkTheme, setIsDarkTheme] = useState(false)
|
|
const [newDeviceLabel, setNewDeviceLabel] = useState("")
|
|
const [newDeviceRatio, setNewDeviceRatio] = useState(50)
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
const saved = localStorage.getItem("theme")
|
|
const dark = saved ? saved === "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
setIsDarkTheme(dark)
|
|
document.documentElement.classList.toggle("dark", dark)
|
|
}
|
|
}, [])
|
|
|
|
const toggleTheme = (checked: boolean) => {
|
|
setIsDarkTheme(checked)
|
|
if (typeof document !== "undefined") {
|
|
document.documentElement.classList.toggle("dark", checked)
|
|
}
|
|
if (typeof localStorage !== "undefined") {
|
|
localStorage.setItem("theme", checked ? "dark" : "light")
|
|
}
|
|
}
|
|
|
|
// Retrait de l'ouverture automatique de la modale d'appareil
|
|
|
|
const showNotification = (type: "success" | "error" | "info", message: string) => {
|
|
setNotification({ type, message })
|
|
setTimeout(() => setNotification(null), 3000)
|
|
}
|
|
|
|
const tabs = [
|
|
{ id: "profile", name: "Profil", icon: User },
|
|
{ id: "devices", name: "Appareils", icon: Smartphone },
|
|
{ id: "import", name: "Import", icon: Upload },
|
|
{ id: "sync", name: "Synchroniser", icon: RefreshCw },
|
|
]
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true)
|
|
// Simuler la sauvegarde
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
setIsSaving(false)
|
|
showNotification("success", "Paramètres sauvegardés avec succès")
|
|
}
|
|
|
|
const handleExportData = () => {
|
|
setShowExportConfirmation(true)
|
|
}
|
|
|
|
const confirmExportData = async () => {
|
|
setShowExportConfirmation(false)
|
|
showNotification("info", "Export des données en cours...")
|
|
|
|
try {
|
|
const data = await exportIndexedDB()
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = `docv-export-${new Date().toISOString().split("T")[0]}.json`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
showNotification("success", "Export terminé. Fichier téléchargé avec succès.")
|
|
} catch (e: any) {
|
|
showNotification("error", e?.message || "Échec de l'export IndexedDB")
|
|
}
|
|
}
|
|
|
|
// Exporter toutes les bases IndexedDB (si supporté)
|
|
async function exportIndexedDB() {
|
|
const result: any = { timestamp: new Date().toISOString(), databases: [] as any[] }
|
|
const dbList = (indexedDB as any).databases ? await (indexedDB as any).databases() : []
|
|
const dbNames: string[] = dbList?.map((d: any) => d.name).filter(Boolean) || []
|
|
// Fallback: si l'API databases() n'est pas dispo, utiliser une liste vide (app ne définit pas de DB explicites)
|
|
for (const name of dbNames) {
|
|
if (!name) continue
|
|
const dbDump: any = { name, version: 1, stores: {} as any }
|
|
const db: IDBDatabase = await new Promise((resolve, reject) => {
|
|
const open = indexedDB.open(name)
|
|
open.onsuccess = () => resolve(open.result)
|
|
open.onerror = () => reject(open.error)
|
|
})
|
|
dbDump.version = db.version
|
|
const storeNames = Array.from(db.objectStoreNames)
|
|
for (const storeName of storeNames) {
|
|
dbDump.stores[storeName] = []
|
|
const tx = db.transaction(storeName, "readonly")
|
|
const store = tx.objectStore(storeName)
|
|
const all: any[] = await new Promise((resolve, reject) => {
|
|
const req = store.getAll()
|
|
req.onsuccess = () => resolve(req.result)
|
|
req.onerror = () => reject(req.error)
|
|
})
|
|
dbDump.stores[storeName] = all
|
|
}
|
|
db.close()
|
|
result.databases.push(dbDump)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function importIndexedDBFromFile(file: File) {
|
|
setIsImporting(true)
|
|
try {
|
|
const text = await file.text()
|
|
const data = JSON.parse(text)
|
|
if (!data?.databases) throw new Error("Fichier d'import invalide")
|
|
for (const dbDump of data.databases) {
|
|
const name = dbDump.name as string
|
|
const version = dbDump.version as number
|
|
const db: IDBDatabase = await new Promise((resolve, reject) => {
|
|
const open = indexedDB.open(name, version)
|
|
open.onupgradeneeded = () => {
|
|
const dbu = open.result
|
|
for (const storeName of Object.keys(dbDump.stores || {})) {
|
|
if (!dbu.objectStoreNames.contains(storeName)) {
|
|
dbu.createObjectStore(storeName, { autoIncrement: true })
|
|
}
|
|
}
|
|
}
|
|
open.onsuccess = () => resolve(open.result)
|
|
open.onerror = () => reject(open.error)
|
|
})
|
|
for (const [storeName, records] of Object.entries<any>(dbDump.stores || {})) {
|
|
const tx = db.transaction(storeName, "readwrite")
|
|
const store = tx.objectStore(storeName)
|
|
await new Promise((resolve, reject) => {
|
|
const clearReq = store.clear()
|
|
clearReq.onsuccess = () => resolve(true)
|
|
clearReq.onerror = () => reject(clearReq.error)
|
|
})
|
|
for (const rec of records) {
|
|
await new Promise((resolve, reject) => {
|
|
const req = store.add(rec)
|
|
req.onsuccess = () => resolve(true)
|
|
req.onerror = () => reject(req.error)
|
|
})
|
|
}
|
|
}
|
|
db.close()
|
|
}
|
|
showNotification("success", "Import terminé avec succès")
|
|
} catch (e: any) {
|
|
showNotification("error", e?.message || "Échec de l'import IndexedDB")
|
|
} finally {
|
|
setIsImporting(false)
|
|
}
|
|
}
|
|
|
|
async function synchronizeIndexedDBPreserveKeys() {
|
|
setIsSyncing(true)
|
|
setSyncProgress(0)
|
|
try {
|
|
const dbList = (indexedDB as any).databases ? await (indexedDB as any).databases() : []
|
|
const dbNames: string[] = dbList?.map((d: any) => d.name).filter(Boolean) || []
|
|
let processed = 0
|
|
for (const name of dbNames) {
|
|
const db: IDBDatabase = await new Promise((resolve, reject) => {
|
|
const open = indexedDB.open(name)
|
|
open.onsuccess = () => resolve(open.result)
|
|
open.onerror = () => reject(open.error)
|
|
})
|
|
const storeNames = Array.from(db.objectStoreNames)
|
|
for (const storeName of storeNames) {
|
|
const shouldPreserve = /key/i.test(storeName)
|
|
if (shouldPreserve) continue
|
|
const tx = db.transaction(storeName, "readwrite")
|
|
const store = tx.objectStore(storeName)
|
|
await new Promise((resolve, reject) => {
|
|
const clearReq = store.clear()
|
|
clearReq.onsuccess = () => resolve(true)
|
|
clearReq.onerror = () => reject(clearReq.error)
|
|
})
|
|
}
|
|
db.close()
|
|
processed += 1
|
|
setSyncProgress(Math.round((processed / Math.max(1, dbNames.length)) * 100))
|
|
}
|
|
// Barre de progression finale
|
|
setSyncProgress(100)
|
|
showNotification("success", "Synchronisation lancée: données (hors clés) vidées")
|
|
} catch (e: any) {
|
|
showNotification("error", e?.message || "Échec de la synchronisation")
|
|
} finally {
|
|
setTimeout(() => setIsSyncing(false), 400)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const generatePairingWords = () => {
|
|
const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"]
|
|
return Array.from({ length: 4 }, () => words[Math.floor(Math.random() * words.length)])
|
|
}
|
|
|
|
const [pairingWords] = useState(generatePairingWords())
|
|
|
|
const handleAddDevice = () => {
|
|
setShowAddDeviceModal(false)
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
security: {
|
|
...prev.security,
|
|
activeDevices: prev.security.activeDevices + 1,
|
|
},
|
|
}))
|
|
showNotification("success", "Instructions d'appairage générées. Suivez les étapes sur votre autre appareil.")
|
|
}
|
|
|
|
const renderProfileTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informations personnelles</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
|
|
<span className="text-blue-600 font-bold text-2xl">
|
|
{settings.profile.firstName.charAt(0)}
|
|
{settings.profile.lastName.charAt(0)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<Button variant="outline" size="sm">
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
Changer la photo
|
|
</Button>
|
|
<p className="text-sm text-gray-500 mt-1">JPG, PNG ou GIF. Max 2MB.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="firstName">Prénom</Label>
|
|
<Input
|
|
id="firstName"
|
|
value={settings.profile.firstName}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, firstName: e.target.value },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="lastName">Nom</Label>
|
|
<Input
|
|
id="lastName"
|
|
value={settings.profile.lastName}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, lastName: e.target.value },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={settings.profile.email}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, email: e.target.value },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="phone">Téléphone</Label>
|
|
<Input
|
|
id="phone"
|
|
value={settings.profile.phone}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, phone: e.target.value },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="position">Poste</Label>
|
|
<Input
|
|
id="position"
|
|
value={settings.profile.position}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, position: e.target.value },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="department">Département</Label>
|
|
<Input
|
|
id="department"
|
|
value={settings.profile.department}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, department: e.target.value },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="bio">Biographie</Label>
|
|
<Textarea
|
|
id="bio"
|
|
value={settings.profile.bio}
|
|
onChange={(e) =>
|
|
setSettings({
|
|
...settings,
|
|
profile: { ...settings.profile, bio: e.target.value },
|
|
})
|
|
}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
const renderDevicesTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Appareils</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Gestion des appareils</h4>
|
|
<p className="text-sm text-gray-500">Définissez un label et un ratio de signature par appareil</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => { setNewDeviceLabel(""); setNewDeviceRatio(50); setShowAddDeviceModal(true) }}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Ajouter un appareil
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{settings.security.devices?.map((dev: any, idx: number) => (
|
|
<div key={dev.id || idx} className="p-3 rounded-lg bg-gray-50 border flex items-center gap-3">
|
|
<Input
|
|
value={dev.label}
|
|
onChange={(e) => setSettings(prev => ({
|
|
...prev,
|
|
security: {
|
|
...prev.security,
|
|
devices: prev.security.devices.map((d: any) => d === dev ? { ...d, label: e.target.value } : d)
|
|
}
|
|
}))}
|
|
className="max-w-xs"
|
|
/>
|
|
{dev.id === "current" && (
|
|
<Badge className="bg-green-100 text-green-800 border-green-200">Actuel</Badge>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<Label>Ratio</Label>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
value={dev.ratio}
|
|
onChange={(e) => setSettings(prev => ({
|
|
...prev,
|
|
security: {
|
|
...prev.security,
|
|
devices: prev.security.devices.map((d: any) => d === dev ? { ...d, ratio: Number(e.target.value) } : d)
|
|
}
|
|
}))}
|
|
/>
|
|
<span className="text-sm text-gray-600 w-10">{dev.ratio}%</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="ml-auto"
|
|
disabled={dev.id === "current"}
|
|
onClick={() => setSettings(prev => ({
|
|
...prev,
|
|
security: { ...prev.security, devices: prev.security.devices.filter((d: any) => d !== dev) }
|
|
}))}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" /> Retirer
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{(!settings.security.devices || settings.security.devices.length === 0) && (
|
|
<p className="text-sm text-gray-500">Aucun appareil pour le moment.</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
const renderNotificationsTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Préférences de notification</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Notifications par email</h4>
|
|
<p className="text-sm text-gray-500">Recevoir des notifications par email</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.notifications.emailNotifications}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
notifications: { ...settings.notifications, emailNotifications: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Notifications push</h4>
|
|
<p className="text-sm text-gray-500">Notifications dans le navigateur</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.notifications.pushNotifications}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
notifications: { ...settings.notifications, pushNotifications: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Mises à jour de documents</h4>
|
|
<p className="text-sm text-gray-500">Quand un document est modifié</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.notifications.documentUpdates}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
notifications: { ...settings.notifications, documentUpdates: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Partage de dossiers</h4>
|
|
<p className="text-sm text-gray-500">Quand un dossier est partagé avec vous</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.notifications.folderSharing}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
notifications: { ...settings.notifications, folderSharing: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Alertes système</h4>
|
|
<p className="text-sm text-gray-500">Notifications importantes du système</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.notifications.systemAlerts}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
notifications: { ...settings.notifications, systemAlerts: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Rapport hebdomadaire</h4>
|
|
<p className="text-sm text-gray-500">Résumé de votre activité chaque semaine</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.notifications.weeklyReport}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
notifications: { ...settings.notifications, weeklyReport: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
const renderAppearanceTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Préférences d'affichage</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="theme">Thème</Label>
|
|
<Select
|
|
value={settings.appearance.theme}
|
|
onValueChange={(value) =>
|
|
setSettings({
|
|
...settings,
|
|
appearance: { ...settings.appearance, theme: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="light">Clair</SelectItem>
|
|
<SelectItem value="dark">Sombre</SelectItem>
|
|
<SelectItem value="auto">Automatique</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="language">Langue</Label>
|
|
<Select
|
|
value={settings.appearance.language}
|
|
onValueChange={(value) =>
|
|
setSettings({
|
|
...settings,
|
|
appearance: { ...settings.appearance, language: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="fr">Français</SelectItem>
|
|
<SelectItem value="en">English</SelectItem>
|
|
<SelectItem value="es">Español</SelectItem>
|
|
<SelectItem value="de">Deutsch</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="timezone">Fuseau horaire</Label>
|
|
<Select
|
|
value={settings.appearance.timezone}
|
|
onValueChange={(value) =>
|
|
setSettings({
|
|
...settings,
|
|
appearance: { ...settings.appearance, timezone: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Europe/Paris">Europe/Paris (UTC+1)</SelectItem>
|
|
<SelectItem value="Europe/London">Europe/London (UTC+0)</SelectItem>
|
|
<SelectItem value="America/New_York">America/New_York (UTC-5)</SelectItem>
|
|
<SelectItem value="Asia/Tokyo">Asia/Tokyo (UTC+9)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="dateFormat">Format de date</Label>
|
|
<Select
|
|
value={settings.appearance.dateFormat}
|
|
onValueChange={(value) =>
|
|
setSettings({
|
|
...settings,
|
|
appearance: { ...settings.appearance, dateFormat: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="dd/mm/yyyy">DD/MM/YYYY</SelectItem>
|
|
<SelectItem value="mm/dd/yyyy">MM/DD/YYYY</SelectItem>
|
|
<SelectItem value="yyyy-mm-dd">YYYY-MM-DD</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Mode compact</h4>
|
|
<p className="text-sm text-gray-500">Interface plus dense</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.appearance.compactMode}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
appearance: { ...settings.appearance, compactMode: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
const renderPrivacyTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Confidentialité et données</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="profileVisibility">Visibilité du profil</Label>
|
|
<Select
|
|
value={settings.privacy.profileVisibility}
|
|
onValueChange={(value) =>
|
|
setSettings({
|
|
...settings,
|
|
privacy: { ...settings.privacy, profileVisibility: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="public">Public</SelectItem>
|
|
<SelectItem value="team">Équipe seulement</SelectItem>
|
|
<SelectItem value="private">Privé</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Suivi d'activité</h4>
|
|
<p className="text-sm text-gray-500">Permettre le suivi de votre activité</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.privacy.activityTracking}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
privacy: { ...settings.privacy, activityTracking: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Partage de données</h4>
|
|
<p className="text-sm text-gray-500">Partager des données anonymisées</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.privacy.dataSharing}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
privacy: { ...settings.privacy, dataSharing: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">Analytics</h4>
|
|
<p className="text-sm text-gray-500">Améliorer l'expérience utilisateur</p>
|
|
</div>
|
|
<Switch
|
|
checked={settings.privacy.analyticsOptIn}
|
|
onCheckedChange={(checked) =>
|
|
setSettings({
|
|
...settings,
|
|
privacy: { ...settings.privacy, analyticsOptIn: checked },
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-red-600">Zone de danger</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="bg-red-50 p-4 rounded-lg">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-medium text-red-900">Supprimer le compte</h4>
|
|
<p className="text-sm text-red-700 mt-1">
|
|
Cette action est irréversible. Toutes vos données seront définitivement supprimées.
|
|
</p>
|
|
<Button variant="outline" className="mt-3 text-red-600 border-red-300 hover:bg-red-50 bg-transparent">
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
Supprimer mon compte
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
const renderImportTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Import</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="importFile">Fichier d'import (.json)</Label>
|
|
<input
|
|
id="importFile"
|
|
type="file"
|
|
accept="application/json"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0]
|
|
if (f) importIndexedDBFromFile(f)
|
|
}}
|
|
disabled={isImporting}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-gray-500">Le contenu remplacera les données locales des stores correspondants.</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
const renderSyncTab = () => (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Synchroniser</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-gray-600">Vide toutes les données IndexedDB en conservant les stores contenant « key ».</p>
|
|
<Button onClick={synchronizeIndexedDBPreserveKeys} disabled={isSyncing}>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Lancer la synchronisation
|
|
</Button>
|
|
{isSyncing && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm text-gray-600">Synchronisation en cours...</span>
|
|
<span className="text-sm text-gray-600">{syncProgress}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
<div className="bg-blue-600 h-3 rounded-full" style={{ width: `${syncProgress}%` }}></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
|
|
|
|
|
|
const renderTabContent = () => {
|
|
switch (activeTab) {
|
|
case "profile":
|
|
return renderProfileTab()
|
|
case "devices":
|
|
return renderDevicesTab()
|
|
// Onglets Notifications/Appearance/Privacy retirés
|
|
case "import":
|
|
return renderImportTab()
|
|
case "sync":
|
|
return renderSyncTab()
|
|
|
|
default:
|
|
return renderProfileTab()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Notification */}
|
|
{notification && (
|
|
<div
|
|
className={`fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg flex items-center space-x-2 ${
|
|
notification.type === "success"
|
|
? "bg-green-100 text-green-800 border border-green-200"
|
|
: notification.type === "error"
|
|
? "bg-red-100 text-red-800 border border-red-200"
|
|
: "bg-blue-100 text-blue-800 border border-blue-200"
|
|
}`}
|
|
>
|
|
{notification.type === "success" && <CheckCircle className="h-5 w-5" />}
|
|
{notification.type === "error" && <X className="h-5 w-5" />}
|
|
{notification.type === "info" && <AlertTriangle className="h-5 w-5" />}
|
|
<span>{notification.message}</span>
|
|
<Button variant="ghost" size="sm" onClick={() => setNotification(null)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Paramètres</h1>
|
|
<p className="text-gray-600 mt-1">Gérez vos préférences et paramètres de compte</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-600">Sombre</span>
|
|
<Switch checked={isDarkTheme} onCheckedChange={toggleTheme} />
|
|
</div>
|
|
<Button variant="outline" onClick={handleExportData}>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Export
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
|
{isSaving ? "Sauvegarde..." : "Sauvegarder"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-6">
|
|
{/* Sidebar */}
|
|
<div className="lg:w-64">
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<nav className="space-y-1">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`w-full flex items-center px-4 py-3 text-left text-sm font-medium transition-colors ${
|
|
activeTab === tab.id
|
|
? "bg-blue-50 text-blue-700 border-r-2 border-blue-600"
|
|
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
|
}`}
|
|
>
|
|
<tab.icon className="h-5 w-5 mr-3" />
|
|
{tab.name}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1">{renderTabContent()}</div>
|
|
</div>
|
|
|
|
{/* Modal d'ajout d'appareil */}
|
|
{showAddDeviceModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<div className="text-center">
|
|
<Shield className="h-12 w-12 mx-auto mb-4 text-blue-600" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ajouter un appareil de confiance</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
Pour renforcer la sécurité de votre compte, ajoutez un second appareil de confiance.
|
|
</p>
|
|
|
|
<div className="bg-blue-50 p-4 rounded-lg mb-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="font-medium text-blue-900">Mots de pairing temporaires</h4>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowPairingWords(!showPairingWords)}
|
|
className="text-blue-700 border-blue-300"
|
|
>
|
|
{showPairingWords ? <EyeOff className="h-4 w-4 mr-1" /> : <Eye className="h-4 w-4 mr-1" />}
|
|
{showPairingWords ? "Masquer" : "Afficher"}
|
|
</Button>
|
|
</div>
|
|
<div
|
|
className="grid grid-cols-2 gap-2 mb-3 select-none"
|
|
style={{ userSelect: "none", WebkitUserSelect: "none" }}
|
|
>
|
|
{pairingWords.map((word, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-white p-2 rounded border font-mono text-center select-none"
|
|
style={{ userSelect: "none", WebkitUserSelect: "none" }}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onDragStart={(e) => e.preventDefault()}
|
|
>
|
|
{showPairingWords ? word : "••••"}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-blue-700">Ces mots expirent dans 10 minutes</p>
|
|
</div>
|
|
|
|
<div className="text-left bg-gray-50 p-4 rounded-lg mb-6">
|
|
<h4 className="font-medium text-gray-900 mb-2">Instructions :</h4>
|
|
<ol className="text-sm text-gray-700 space-y-1">
|
|
<li>1. Allez sur DocV avec votre autre appareil</li>
|
|
<li>2. Cliquez sur "Pairing" sur la page de connexion</li>
|
|
<li>3. Saisissez les 4 mots ci-dessus</li>
|
|
<li>4. Votre appareil apparaîtra automatiquement</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Button onClick={handleAddDevice} className="w-full">
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
J'ai suivi les instructions
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setShowAddDeviceModal(false)} className="w-full">
|
|
Plus tard
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500 mt-4">
|
|
Vous pouvez toujours ajouter un appareil plus tard depuis les paramètres de sécurité
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de confirmation d'export */}
|
|
{showExportConfirmation && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<div className="text-center">
|
|
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-orange-600" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Confirmer l'export des données</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Cette action va exporter toutes vos données stockées localement, y compris votre clé privée.
|
|
</p>
|
|
|
|
<div className="bg-red-50 p-4 rounded-lg mb-6 border border-red-200">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5 flex-shrink-0" />
|
|
<div className="text-left">
|
|
<h4 className="font-medium text-red-900 mb-1">⚠️ Attention - Clé privée incluse</h4>
|
|
<p className="text-sm text-red-700">
|
|
Le fichier exporté contiendra votre clé privée chiffrée. Gardez ce fichier en sécurité et ne le
|
|
partagez jamais.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-left bg-blue-50 p-4 rounded-lg mb-6">
|
|
<h4 className="font-medium text-blue-900 mb-2">Contenu de l'export :</h4>
|
|
<ul className="text-sm text-blue-700 space-y-1">
|
|
<li>• Paramètres utilisateur</li>
|
|
<li>• Documents et métadonnées</li>
|
|
<li>• Historique des dossiers</li>
|
|
<li>• Certificats blockchain</li>
|
|
<li>• Clé privée chiffrée 🔐</li>
|
|
<li>• Historique des conversations</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Button onClick={confirmExportData} className="w-full">
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Confirmer l'export
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setShowExportConfirmation(false)} className="w-full">
|
|
Annuler
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500 mt-4">
|
|
L'export peut prendre quelques minutes selon la quantité de données
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|