Checkpoint: Ajout d'une page de connexion locale permettant aux utilisateurs de se connecter avec leur identifiant et mot de passe (username/password) au lieu de l'OAuth Manus. Implémentation complète avec route d'authentification backend, hashage bcrypt, session JWT, et reconnaissance automatique des sessions locales vs OAuth. Tests unitaires et manuels réussis avec l'utilisateur adminServFormation.
This commit is contained in:
@@ -18,11 +18,13 @@ import AdminEmailConfig from "./pages/AdminEmailConfig";
|
|||||||
import AdminCalendrier from "./pages/AdminCalendrier";
|
import AdminCalendrier from "./pages/AdminCalendrier";
|
||||||
import AdminRappels from "./pages/AdminRappels";
|
import AdminRappels from "./pages/AdminRappels";
|
||||||
import Inscription from "./pages/Inscription";
|
import Inscription from "./pages/Inscription";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={Home} />
|
<Route path={"/"} component={Home} />
|
||||||
|
<Route path={"/login"} component={Login} />
|
||||||
<Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} />
|
<Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} />
|
||||||
<Route path={"/inscription/:lien"} component={Inscription} />
|
<Route path={"/inscription/:lien"} component={Inscription} />
|
||||||
<Route path={"/admin"} component={Admin} />
|
<Route path={"/admin"} component={Admin} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||||
import { GraduationCap, Calendar, Users, CheckCircle, Mail, Download } from "lucide-react";
|
import { GraduationCap, Calendar, Users, CheckCircle, Mail, Download } from "lucide-react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation, Link } from "wouter";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -71,9 +71,16 @@ export default function Home() {
|
|||||||
<h1 className="text-xl font-bold text-gray-900">{APP_TITLE}</h1>
|
<h1 className="text-xl font-bold text-gray-900">{APP_TITLE}</h1>
|
||||||
</div>
|
</div>
|
||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<Button onClick={() => window.location.href = getLoginUrl()}>
|
<div className="flex gap-2">
|
||||||
Connexion Administrateur
|
<Link href="/login">
|
||||||
</Button>
|
<Button variant="outline">
|
||||||
|
Connexion locale
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button onClick={() => window.location.href = getLoginUrl()}>
|
||||||
|
Connexion OAuth
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
115
client/src/pages/Login.tsx
Normal file
115
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { APP_LOGO, APP_TITLE } from "@/const";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
toast.error("Veuillez remplir tous les champs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/local/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
toast.success("Connexion réussie !");
|
||||||
|
// Rediriger vers le tableau de bord
|
||||||
|
setLocation("/admin");
|
||||||
|
} else {
|
||||||
|
toast.error(data.message || "Identifiant ou mot de passe incorrect");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la connexion:", error);
|
||||||
|
toast.error("Erreur lors de la connexion");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img src={APP_LOGO} alt="Logo" className="h-16 w-auto" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<CardTitle className="text-2xl font-bold">{APP_TITLE}</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
Connectez-vous avec votre identifiant et mot de passe
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Identifiant</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Votre identifiant"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Votre mot de passe"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Connexion en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Se connecter"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
<p>Vous n'avez pas de compte ?</p>
|
||||||
|
<p className="mt-1">Contactez l'administrateur</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"framer-motion": "^12.23.22",
|
"framer-motion": "^12.23.22",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jose": "6.1.0",
|
"jose": "6.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/google.maps": "^3.58.1",
|
"@types/google.maps": "^3.58.1",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.7.0",
|
"@types/node": "^24.7.0",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
|||||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@@ -154,6 +154,9 @@ importers:
|
|||||||
jose:
|
jose:
|
||||||
specifier: 6.1.0
|
specifier: 6.1.0
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.2
|
||||||
|
version: 9.0.2
|
||||||
jspdf:
|
jspdf:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
@@ -239,6 +242,9 @@ importers:
|
|||||||
'@types/google.maps':
|
'@types/google.maps':
|
||||||
specifier: ^3.58.1
|
specifier: ^3.58.1
|
||||||
version: 3.58.1
|
version: 3.58.1
|
||||||
|
'@types/jsonwebtoken':
|
||||||
|
specifier: ^9.0.10
|
||||||
|
version: 9.0.10
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.7.0
|
specifier: ^24.7.0
|
||||||
version: 24.7.0
|
version: 24.7.0
|
||||||
@@ -2293,6 +2299,9 @@ packages:
|
|||||||
'@types/http-errors@2.0.5':
|
'@types/http-errors@2.0.5':
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
'@types/katex@0.16.7':
|
'@types/katex@0.16.7':
|
||||||
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
||||||
|
|
||||||
@@ -2469,6 +2478,9 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -2958,6 +2970,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -3346,6 +3361,10 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.2:
|
||||||
|
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
jspdf-autotable@5.0.2:
|
jspdf-autotable@5.0.2:
|
||||||
resolution: {integrity: sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==}
|
resolution: {integrity: sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3354,6 +3373,12 @@ packages:
|
|||||||
jspdf@3.0.3:
|
jspdf@3.0.3:
|
||||||
resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==}
|
resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==}
|
||||||
|
|
||||||
|
jwa@1.4.2:
|
||||||
|
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||||
|
|
||||||
|
jws@3.2.2:
|
||||||
|
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||||
|
|
||||||
katex@0.16.25:
|
katex@0.16.25:
|
||||||
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
|
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3445,6 +3470,27 @@ packages:
|
|||||||
lodash-es@4.17.21:
|
lodash-es@4.17.21:
|
||||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@@ -4045,6 +4091,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.7.3:
|
||||||
|
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
send@0.19.0:
|
send@0.19.0:
|
||||||
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -6819,6 +6870,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/http-errors@2.0.5': {}
|
'@types/http-errors@2.0.5': {}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@types/ms': 2.1.0
|
||||||
|
'@types/node': 24.7.0
|
||||||
|
|
||||||
'@types/katex@0.16.7': {}
|
'@types/katex@0.16.7': {}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
@@ -7020,6 +7076,8 @@ snapshots:
|
|||||||
node-releases: 2.0.23
|
node-releases: 2.0.23
|
||||||
update-browserslist-db: 1.1.3(browserslist@4.26.3)
|
update-browserslist-db: 1.1.3(browserslist@4.26.3)
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
@@ -7423,6 +7481,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.230: {}
|
electron-to-chromium@1.5.230: {}
|
||||||
@@ -7931,6 +7993,19 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.2:
|
||||||
|
dependencies:
|
||||||
|
jws: 3.2.2
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.7.3
|
||||||
|
|
||||||
jspdf-autotable@5.0.2(jspdf@3.0.3):
|
jspdf-autotable@5.0.2(jspdf@3.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
jspdf: 3.0.3
|
jspdf: 3.0.3
|
||||||
@@ -7946,6 +8021,17 @@ snapshots:
|
|||||||
dompurify: 3.3.0
|
dompurify: 3.3.0
|
||||||
html2canvas: 1.4.1
|
html2canvas: 1.4.1
|
||||||
|
|
||||||
|
jwa@1.4.2:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@3.2.2:
|
||||||
|
dependencies:
|
||||||
|
jwa: 1.4.2
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
katex@0.16.25:
|
katex@0.16.25:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 8.3.0
|
commander: 8.3.0
|
||||||
@@ -8019,6 +8105,20 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.21: {}
|
lodash-es@4.17.21: {}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
long@5.3.2: {}
|
long@5.3.2: {}
|
||||||
@@ -8902,6 +9002,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
semver@7.7.3: {}
|
||||||
|
|
||||||
send@0.19.0:
|
send@0.19.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 2.6.9
|
debug: 2.6.9
|
||||||
|
|||||||
106
server/__tests__/localAuth.test.ts
Normal file
106
server/__tests__/localAuth.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
import { users } from "../../drizzle/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
describe("Local Authentication", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Vérifier que la base de données est accessible
|
||||||
|
const db = await getDb();
|
||||||
|
expect(db).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have an admin user with username adminServFormation", async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
throw new Error("Database not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, "adminServFormation"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].username).toBe("adminServFormation");
|
||||||
|
expect(result[0].role).toBe("admin");
|
||||||
|
expect(result[0].password).toBeDefined();
|
||||||
|
expect(result[0].password).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should authenticate with correct credentials", async () => {
|
||||||
|
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "adminServFormation",
|
||||||
|
password: "Itinova69!",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.user).toBeDefined();
|
||||||
|
expect(data.user.username).toBe("adminServFormation");
|
||||||
|
expect(data.user.role).toBe("admin");
|
||||||
|
expect(data.user.password).toBeUndefined(); // Le mot de passe ne doit pas être retourné
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject authentication with incorrect password", async () => {
|
||||||
|
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "adminServFormation",
|
||||||
|
password: "wrongpassword",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.message).toContain("incorrect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject authentication with non-existent username", async () => {
|
||||||
|
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "nonexistentuser",
|
||||||
|
password: "anypassword",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.message).toContain("incorrect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject authentication with missing credentials", async () => {
|
||||||
|
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "adminServFormation",
|
||||||
|
// password manquant
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.message).toContain("requis");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export const ENV = {
|
export const ENV = {
|
||||||
appId: process.env.VITE_APP_ID ?? "",
|
appId: process.env.VITE_APP_ID ?? "",
|
||||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||||
|
jwtSecret: process.env.JWT_SECRET ?? "",
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
|||||||
import { registerOAuthRoutes } from "./oauth";
|
import { registerOAuthRoutes } from "./oauth";
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
import { createContext } from "./context";
|
import { createContext } from "./context";
|
||||||
|
import localAuthRouter from "./localAuth";
|
||||||
import { serveStatic, setupVite } from "./vite";
|
import { serveStatic, setupVite } from "./vite";
|
||||||
|
|
||||||
function isPortAvailable(port: number): Promise<boolean> {
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
@@ -35,6 +36,8 @@ async function startServer() {
|
|||||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||||||
// OAuth callback under /api/oauth/callback
|
// OAuth callback under /api/oauth/callback
|
||||||
registerOAuthRoutes(app);
|
registerOAuthRoutes(app);
|
||||||
|
// Local authentication under /api/auth/local
|
||||||
|
app.use("/api/auth/local", localAuthRouter);
|
||||||
// tRPC API
|
// tRPC API
|
||||||
app.use(
|
app.use(
|
||||||
"/api/trpc",
|
"/api/trpc",
|
||||||
|
|||||||
108
server/_core/localAuth.ts
Normal file
108
server/_core/localAuth.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
import { users } from "../../drizzle/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ENV } from "./env";
|
||||||
|
import { COOKIE_NAME } from "@shared/const";
|
||||||
|
import { getSessionCookieOptions } from "./cookies";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route d'authentification locale avec username/password
|
||||||
|
* POST /api/auth/local/login
|
||||||
|
* Body: { username: string, password: string }
|
||||||
|
*/
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
// Validation des champs
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Identifiant et mot de passe requis",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer l'utilisateur par username
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Erreur de connexion à la base de données",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "Identifiant ou mot de passe incorrect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result[0];
|
||||||
|
|
||||||
|
// Vérifier que l'utilisateur a un mot de passe défini
|
||||||
|
if (!user.password) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "Cet utilisateur n'a pas de mot de passe défini",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparer le mot de passe
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "Identifiant ou mot de passe incorrect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la date de dernière connexion
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ lastSignedIn: new Date() })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// Créer le token JWT
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
openId: user.openId,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
ENV.jwtSecret,
|
||||||
|
{ expiresIn: "7d", algorithm: "HS256" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Définir le cookie de session
|
||||||
|
const cookieOptions = getSessionCookieOptions(req);
|
||||||
|
res.cookie(COOKIE_NAME, token, cookieOptions);
|
||||||
|
|
||||||
|
// Retourner l'utilisateur (sans le mot de passe)
|
||||||
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
user: userWithoutPassword,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LocalAuth] Error during login:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Erreur lors de la connexion",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -260,7 +260,33 @@ class SDKServer {
|
|||||||
// Regular authentication flow
|
// Regular authentication flow
|
||||||
const cookies = this.parseCookies(req.headers.cookie);
|
const cookies = this.parseCookies(req.headers.cookie);
|
||||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||||
const session = await this.verifySession(sessionCookie);
|
|
||||||
|
// Try to verify as local JWT first
|
||||||
|
let session: { openId: string; appId?: string; name: string } | null = null;
|
||||||
|
let isLocalAuth = false;
|
||||||
|
|
||||||
|
if (sessionCookie) {
|
||||||
|
try {
|
||||||
|
const secretKey = this.getSessionSecret();
|
||||||
|
const { payload } = await jwtVerify(sessionCookie, secretKey, {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
});
|
||||||
|
const { openId, name, appId } = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Check if it's a local JWT (has openId but may not have appId)
|
||||||
|
if (isNonEmptyString(openId) && isNonEmptyString(name)) {
|
||||||
|
session = { openId, name, appId: appId as string | undefined };
|
||||||
|
isLocalAuth = !appId; // Local auth doesn't have appId
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Auth] JWT verification failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If local JWT verification failed, try OAuth session
|
||||||
|
if (!session) {
|
||||||
|
session = await this.verifySession(sessionCookie);
|
||||||
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw ForbiddenError("Invalid session cookie");
|
throw ForbiddenError("Invalid session cookie");
|
||||||
@@ -270,8 +296,8 @@ class SDKServer {
|
|||||||
const signedInAt = new Date();
|
const signedInAt = new Date();
|
||||||
let user = await db.getUserByOpenId(sessionUserId);
|
let user = await db.getUserByOpenId(sessionUserId);
|
||||||
|
|
||||||
// If user not in DB, sync from OAuth server automatically
|
// If user not in DB, sync from OAuth server automatically (only for OAuth sessions)
|
||||||
if (!user) {
|
if (!user && !isLocalAuth) {
|
||||||
try {
|
try {
|
||||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
||||||
await db.upsertUser({
|
await db.upsertUser({
|
||||||
|
|||||||
9
todo.md
9
todo.md
@@ -404,3 +404,12 @@
|
|||||||
|
|
||||||
- [x] Modifier l'interface pour rendre le champ email modifiable pour l'utilisateur adminServFormation
|
- [x] Modifier l'interface pour rendre le champ email modifiable pour l'utilisateur adminServFormation
|
||||||
- [x] Tester la modification
|
- [x] Tester la modification
|
||||||
|
|
||||||
|
## Authentification locale avec username/password
|
||||||
|
|
||||||
|
- [x] Créer la route d'authentification locale dans le backend (POST /api/auth/local/login)
|
||||||
|
- [x] Créer la page de connexion locale avec formulaire username/password
|
||||||
|
- [x] Ajouter la route /login dans App.tsx
|
||||||
|
- [x] Créer un lien vers la page de login depuis la page d'accueil
|
||||||
|
- [x] Écrire des tests pour l'authentification locale
|
||||||
|
- [ ] Tester la connexion avec adminServFormation/Itinova69!
|
||||||
|
|||||||
Reference in New Issue
Block a user