feat: adding login/RegisterForm and new columns in PG

This commit is contained in:
Julien Froidefond
2025-08-25 14:02:07 +02:00
parent ee58eb82e5
commit caf396d964
9 changed files with 582 additions and 19 deletions

View File

@@ -1,11 +1,7 @@
import { redirect } from "next/navigation";
import { TeamsService, userService } from "@/services";
import { AuthService } from "@/services";
import {
LoginLayout,
LoginFormWrapper,
LoginLoading,
} from "@/components/login";
import { LoginLayout, AuthWrapper, LoginLoading } from "@/components/login";
export default async function LoginPage() {
try {
@@ -20,19 +16,15 @@ export default async function LoginPage() {
const userProfile = await userService.getUserByUuid(userUuid);
if (userProfile) {
// Passer le profil utilisateur pour permettre la modification
return (
<LoginLayout>
<LoginFormWrapper teams={teams} initialUser={userProfile} />
</LoginLayout>
);
// Rediriger vers l'accueil si déjà connecté
redirect("/");
}
}
// Si l'utilisateur n'est pas connecté, afficher le formulaire de connexion
// Si l'utilisateur n'est pas connecté, afficher le formulaire d'auth
return (
<LoginLayout>
<LoginFormWrapper teams={teams} />
<AuthWrapper teams={teams} />
</LoginLayout>
);
} catch (error) {

View File

@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { LoginForm, RegisterForm } from "./index";
interface AuthWrapperProps {
teams: any[];
}
export function AuthWrapper({ teams }: AuthWrapperProps) {
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const handleLogin = async (email: string, password: string) => {
setLoading(true);
try {
// TODO: Implémenter la logique de login
console.log("Login attempt:", { email, password });
// await authClient.login(email, password);
} catch (error) {
console.error("Login failed:", error);
} finally {
setLoading(false);
}
};
const handleRegister = async (data: {
firstName: string;
lastName: string;
email: string;
password: string;
teamId: string;
}) => {
setLoading(true);
try {
// TODO: Implémenter la logique de register
console.log("Register attempt:", data);
// await authClient.register(data);
} catch (error) {
console.error("Register failed:", error);
} finally {
setLoading(false);
}
};
if (isLogin) {
return (
<LoginForm
onSubmit={handleLogin}
onSwitchToRegister={() => setIsLogin(false)}
loading={loading}
/>
);
}
return (
<RegisterForm
teams={teams}
onSubmit={handleRegister}
onSwitchToLogin={() => setIsLogin(true)}
loading={loading}
/>
);
}

View File

@@ -1,3 +1,6 @@
export { LoginFormWrapper } from "./login-form-wrapper";
export { LoginForm } from "./login-form";
export { RegisterForm } from "./register-form";
export { AuthWrapper } from "./auth-wrapper";
export { LoginLoading } from "./login-loading";
export { LoginLayout } from "./login-layout";

View File

@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Code2 } from "lucide-react";
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
onSwitchToRegister: () => void;
loading?: boolean;
}
export function LoginForm({
onSubmit,
onSwitchToRegister,
loading = false,
}: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (email && password) {
onSubmit(email, password);
}
};
const isValid = email.length > 0 && password.length > 0;
return (
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-sm mb-6">
<Code2 className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">PeakSkills</span>
</div>
<h1 className="text-4xl font-bold mb-4 text-white">Connexion</h1>
<p className="text-lg text-slate-400 mb-8">
Connectez-vous à votre compte PeakSkills
</p>
<div className="max-w-md mx-auto">
<Card className="bg-white/5 border-white/10 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-white">Se connecter</CardTitle>
<CardDescription className="text-slate-400">
Entrez vos identifiants pour accéder à votre compte
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-slate-300">
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="votre@email.com"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-slate-300">
Mot de passe
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Votre mot de passe"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
<Button
type="submit"
className="w-full bg-blue-500 hover:bg-blue-600 text-white"
disabled={!isValid || loading}
>
{loading ? "Connexion..." : "Se connecter"}
</Button>
</form>
<div className="mt-6 pt-6 border-t border-white/10">
<p className="text-sm text-slate-400">
Pas encore de compte ?{" "}
<button
type="button"
onClick={onSwitchToRegister}
className="text-blue-400 hover:text-blue-300 underline"
>
Créer un compte
</button>
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,327 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Team } from "@/lib/types";
import { Search, Building2, ChevronDown, Check, Code2 } from "lucide-react";
interface RegisterFormProps {
teams: Team[];
onSubmit: (data: {
firstName: string;
lastName: string;
email: string;
password: string;
teamId: string;
}) => void;
onSwitchToLogin: () => void;
loading?: boolean;
}
export function RegisterForm({
teams,
onSubmit,
onSwitchToLogin,
loading = false,
}: RegisterFormProps) {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [teamId, setTeamId] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [isTeamDropdownOpen, setIsTeamDropdownOpen] = useState(false);
const teamDropdownRef = useRef<HTMLDivElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<"below" | "above">(
"below"
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
alert("Les mots de passe ne correspondent pas");
return;
}
if (firstName && lastName && email && password && teamId) {
onSubmit({ firstName, lastName, email, password, teamId });
}
};
const isValid =
firstName.length > 0 &&
lastName.length > 0 &&
email.length > 0 &&
password.length > 0 &&
password === confirmPassword &&
teamId.length > 0;
// Group teams by direction and filter by search term
const teamsByDirection = useMemo(() => {
const filteredTeams = teams.filter(
(team) =>
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
team.direction.toLowerCase().includes(searchTerm.toLowerCase())
);
return filteredTeams.reduce((acc, team) => {
if (!acc[team.direction]) {
acc[team.direction] = [];
}
acc[team.direction].push(team);
return acc;
}, {} as Record<string, Team[]>);
}, [teams, searchTerm]);
// Calculate dropdown position when opening
const handleDropdownToggle = () => {
if (!isTeamDropdownOpen && teamDropdownRef.current) {
const rect = teamDropdownRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
const dropdownHeight = 300;
setDropdownPosition(
spaceBelow >= dropdownHeight || spaceBelow > spaceAbove
? "below"
: "above"
);
}
setIsTeamDropdownOpen(!isTeamDropdownOpen);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
teamDropdownRef.current &&
!teamDropdownRef.current.contains(event.target as Node)
) {
setIsTeamDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedTeam = teams.find((team) => team.id === teamId);
return (
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-sm mb-6">
<Code2 className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">PeakSkills</span>
</div>
<h1 className="text-4xl font-bold mb-4 text-white">Créer un compte</h1>
<p className="text-lg text-slate-400 mb-8">
Rejoignez PeakSkills et commencez votre évaluation
</p>
<div className="max-w-md mx-auto">
<Card className="bg-white/5 border-white/10 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-white">Inscription</CardTitle>
<CardDescription className="text-slate-400">
Créez votre compte pour commencer
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName" className="text-slate-300">
Prénom
</Label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Votre prénom"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName" className="text-slate-300">
Nom
</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Votre nom"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-slate-300">
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="votre@email.com"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password" className="text-slate-300">
Mot de passe
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-slate-300">
Confirmer
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirmer"
required
className="bg-white/10 border-white/20 text-white placeholder:text-slate-400"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="team" className="text-slate-300">
Équipe
</Label>
<div className="relative" ref={teamDropdownRef}>
<Button
type="button"
variant="outline"
className="w-full justify-between bg-white/10 border-white/20 text-white hover:bg-white/20"
onClick={handleDropdownToggle}
>
<span
className={selectedTeam ? "text-white" : "text-slate-400"}
>
{selectedTeam
? selectedTeam.name
: "Sélectionnez votre équipe"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isTeamDropdownOpen && (
<div
className={`absolute left-0 right-0 bg-gray-800 border border-white/20 rounded-md shadow-lg z-50 max-h-[300px] overflow-y-auto ${
dropdownPosition === "below"
? "top-full mt-1"
: "bottom-full mb-1"
}`}
>
<div className="sticky top-0 bg-gray-800 border-b border-white/20 p-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="Rechercher une équipe..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8 h-8 text-sm bg-gray-700 border-white/20 text-white"
autoFocus
/>
</div>
</div>
{Object.entries(teamsByDirection).map(
([direction, directionTeams]) => (
<div key={direction}>
<div className="px-3 py-2 text-sm font-semibold text-slate-400 bg-gray-700/50 border-b border-white/20 flex items-center gap-2">
<Building2 className="h-3 w-3" />
{direction}
</div>
{directionTeams.map((team) => (
<button
key={team.id}
type="button"
className={`w-full px-3 py-2 text-left hover:bg-gray-700/50 flex items-center justify-between text-white ${
team.id === teamId ? "bg-blue-600/20" : ""
}`}
onClick={() => {
setTeamId(team.id);
setIsTeamDropdownOpen(false);
setSearchTerm("");
}}
>
<span>{team.name}</span>
{team.id === teamId && (
<Check className="h-4 w-4 text-blue-400" />
)}
</button>
))}
</div>
)
)}
{Object.keys(teamsByDirection).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-slate-400">
Aucune équipe trouvée pour "{searchTerm}"
</div>
)}
</div>
)}
</div>
</div>
<Button
type="submit"
className="w-full bg-blue-500 hover:bg-blue-600 text-white"
disabled={!isValid || loading}
>
{loading ? "Création..." : "Créer le compte"}
</Button>
</form>
<div className="mt-6 pt-6 border-t border-white/10">
<p className="text-sm text-slate-400">
Déjà un compte ?{" "}
<button
type="button"
onClick={onSwitchToLogin}
className="text-blue-400 hover:text-blue-300 underline"
>
Se connecter
</button>
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -44,7 +44,10 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@types/bcrypt": "^6.0.0",
"@types/pg": "^8.11.10",
"autoprefixer": "^10.4.20",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
@@ -55,6 +58,7 @@
"lucide-react": "^0.454.0",
"next": "15.2.4",
"next-themes": "^0.4.6",
"pg": "^8.12.0",
"react": "^19",
"react-day-picker": "9.8.0",
"react-dom": "^19",
@@ -65,9 +69,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "3.25.67",
"pg": "^8.12.0",
"@types/pg": "^8.11.10"
"zod": "3.25.67"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
@@ -76,8 +78,8 @@
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"tsx": "^4.19.2",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}

34
pnpm-lock.yaml generated
View File

@@ -104,12 +104,18 @@ importers:
'@radix-ui/react-tooltip':
specifier: 1.1.6
version: 1.1.6(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/pg':
specifier: ^8.11.10
version: 8.15.5
autoprefixer:
specifier: ^10.4.20
version: 10.4.21(postcss@8.5.6)
bcrypt:
specifier: ^6.0.0
version: 6.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1345,6 +1351,9 @@ packages:
'@tailwindcss/postcss@4.1.12':
resolution: {integrity: sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -1397,6 +1406,10 @@ packages:
peerDependencies:
postcss: ^8.1.0
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
browserslist@4.25.3:
resolution: {integrity: sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -1712,6 +1725,14 @@ packages:
sass:
optional: true
node-addon-api@8.5.0:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@@ -3042,6 +3063,10 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.12
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.17.2
'@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {}
@@ -3098,6 +3123,11 @@ snapshots:
postcss: 8.5.6
postcss-value-parser: 4.2.0
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.5.0
node-gyp-build: 4.8.4
browserslist@4.25.3:
dependencies:
caniuse-lite: 1.0.30001735
@@ -3392,6 +3422,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-addon-api@8.5.0: {}
node-gyp-build@4.8.4: {}
node-releases@2.0.19: {}
normalize-range@0.1.2: {}

View File

@@ -49,10 +49,11 @@ CREATE TABLE users (
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
team_id VARCHAR(50) REFERENCES teams(id),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (uuid_id),
UNIQUE(first_name, last_name, team_id),
UNIQUE(uuid_id)
);
@@ -89,7 +90,8 @@ CREATE INDEX idx_teams_direction ON teams(direction);
CREATE INDEX idx_skills_category_id ON skills(category_id);
CREATE INDEX idx_skill_links_skill_id ON skill_links(skill_id);
CREATE INDEX idx_users_team_id ON users(team_id);
CREATE INDEX idx_users_unique_person ON users(first_name, last_name, team_id);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_uuid_id ON users(uuid_id); -- Index on UUID for performance
CREATE INDEX idx_user_evaluations_user_uuid ON user_evaluations(user_uuid);
CREATE INDEX idx_user_evaluations_user_id ON user_evaluations(user_id); -- Legacy index

View File

@@ -0,0 +1,22 @@
-- Ajout des champs email et password à la table users
-- Étape 1: Ajouter les colonnes sans contraintes NOT NULL
ALTER TABLE users
ADD COLUMN email VARCHAR(255),
ADD COLUMN password_hash VARCHAR(255);
-- Étape 2: Remplir les colonnes avec des valeurs par défaut pour les données existantes
UPDATE users
SET
email = CONCAT(LOWER(first_name), '.', LOWER(last_name), '@peakskills.local'),
password_hash = '$2b$10$default.hash.for.existing.users.placeholder'
WHERE email IS NULL;
-- Étape 3: Ajouter les contraintes NOT NULL après avoir rempli les données
ALTER TABLE users
ALTER COLUMN email SET NOT NULL,
ALTER COLUMN password_hash SET NOT NULL;
-- Étape 4: Ajouter l'index et la contrainte unique sur l'email
CREATE INDEX idx_users_email ON users(email);
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);