300 lines
No EOL
11 KiB
TypeScript
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>
|
|
);
|
|
} |