espace-paie-odentas/app/(app)/compte/securite/page.tsx
2025-10-12 17:05:46 +02:00

300 lines
No EOL
11 KiB
TypeScript

"use client";
import React, { useState, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { KeyRound, Lock, Loader2, Check, X, Eye, EyeOff } from "lucide-react";
import { MfaSetupComponent } from "@/components/auth/MfaSetupComponent";
import { usePageTitle } from "@/hooks/usePageTitle";
/**
* Critères de validation du mot de passe
*/
const validatePassword = (password: string) => {
return {
minLength: password.length >= 12,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumber: /\d/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
};
};
const isPasswordValid = (validation: ReturnType<typeof validatePassword>) => {
return Object.values(validation).every(Boolean);
};
/**
* Page Compte / Sécurité
* - Création / mise à jour du mot de passe (facultatif)
* - 2FA désactivé temporairement
*/
export default function CompteSecuritePage() {
usePageTitle("Sécurité");
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
React.useEffect(() => {
let abort = false;
(async () => {
try {
const res = await fetch("/api/auth/password-status", {
credentials: "include",
cache: "no-store"
});
if (abort) return;
if (res.ok) {
const data = await res.json();
setHasPassword(data.hasPassword);
} else {
setHasPassword(null);
}
} catch (error) {
if (!abort) setHasPassword(null);
}
})();
return () => { abort = true; };
}, []);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwBusy, setPwBusy] = useState(false);
const [showPw1, setShowPw1] = useState(false);
const [showPw2, setShowPw2] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
// Validation en temps réel du mot de passe
const passwordValidation = useMemo(() => validatePassword(pw1), [pw1]);
const isValidPassword = useMemo(() => isPasswordValid(passwordValidation), [passwordValidation]);
// Validation de la confirmation du mot de passe
const passwordMatch = useMemo(() => {
if (!pw2) return null; // Pas d'indication si vide
return pw1 === pw2;
}, [pw1, pw2]);
async function handleSetPassword() {
setPwMsg(null);
if (!isValidPassword) {
setPwMsg("Le mot de passe ne respecte pas tous les critères requis.");
return;
}
if (pw1 !== pw2) {
setPwMsg("Les mots de passe ne correspondent pas.");
return;
}
try {
setPwBusy(true);
const res = await fetch("/api/auth/password-update", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ newPassword: pw1 }),
});
if (res.ok) {
const data = await res.json();
setPw1("");
setPw2("");
setHasPassword(true);
setPwMsg("Mot de passe mis à jour ✨");
} else {
const errorData = await res.json();
setPwMsg(errorData.message || "Échec de la mise à jour");
}
} catch (e: any) {
setPwMsg(e?.message || "Erreur de connexion");
} finally {
setPwBusy(false);
}
}
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Sécurité du compte</h2>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Mot de passe (facultatif) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5"/>
Mot de passe (facultatif)
</CardTitle>
<CardDescription>
Ajoutez ou mettez à jour un mot de passe pour vous connecter sans envoi de code par e-mail.
</CardDescription>
<div className="mt-2">
{hasPassword === null ? (
<span className="inline-block text-xs text-slate-500">Vérification du statut</span>
) : hasPassword ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-emerald-100 text-emerald-700">
Mot de passe défini
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700">
Aucun mot de passe défini
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2">
<Label htmlFor="pw1">Nouveau mot de passe</Label>
<div className="relative">
<Input
id="pw1"
type={showPw1 ? "text" : "password"}
value={pw1}
onChange={(e)=>setPw1(e.target.value)}
placeholder="Saisissez votre nouveau mot de passe"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPw1(!showPw1)}
>
{showPw1 ? <EyeOff className="h-4 w-4 text-gray-500" /> : <Eye className="h-4 w-4 text-gray-500" />}
</Button>
</div>
{/* Critères de validation en temps réel */}
{pw1.length > 0 && (
<div className="mt-2 p-3 bg-slate-50 rounded-lg border">
<div className="text-sm font-medium text-slate-700 mb-2">
Critères requis :
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
{passwordValidation.minLength ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.minLength ? "text-green-700" : "text-red-700"}>
Au moins 12 caractères
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasLowercase ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasLowercase ? "text-green-700" : "text-red-700"}>
Au moins une minuscule (a-z)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasUppercase ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasUppercase ? "text-green-700" : "text-red-700"}>
Au moins une majuscule (A-Z)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasNumber ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasNumber ? "text-green-700" : "text-red-700"}>
Au moins un chiffre (0-9)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasSpecialChar ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasSpecialChar ? "text-green-700" : "text-red-700"}>
Au moins un caractère spécial (!@#$%^&*)
</span>
</div>
</div>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="pw2">Confirmer le mot de passe</Label>
<div className="relative">
<Input
id="pw2"
type={showPw2 ? "text" : "password"}
value={pw2}
onChange={(e)=>setPw2(e.target.value)}
placeholder="Confirmez votre mot de passe"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPw2(!showPw2)}
>
{showPw2 ? <EyeOff className="h-4 w-4 text-gray-500" /> : <Eye className="h-4 w-4 text-gray-500" />}
</Button>
</div>
{/* Indication de correspondance du mot de passe */}
{pw2.length > 0 && (
<div className="flex items-center gap-2 text-sm">
{passwordMatch === true ? (
<>
<Check className="h-4 w-4 text-green-600" />
<span className="text-green-700">
Les mots de passe correspondent
</span>
</>
) : (
<>
<X className="h-4 w-4 text-red-600" />
<span className="text-red-700">
Les mots de passe ne correspondent pas
</span>
</>
)}
</div>
)}
</div>
{pwMsg && (
<div className={`text-sm ${pwMsg.includes("✨") ? 'text-emerald-600' : 'text-rose-600'}`}>
{pwMsg}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSetPassword}
disabled={pwBusy || !isValidPassword || pw1 !== pw2 || !pw1 || !pw2}
>
{pwBusy && <Loader2 className="h-4 w-4 mr-2 animate-spin"/>}
Enregistrer
</Button>
</div>
</CardContent>
</Card>
{/* 2FA / MFA TOTP */}
<MfaSetupComponent />
</div>
</div>
);
}