From caf396d9646234c3ad3c4f15292b4bd824124c1f Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Mon, 25 Aug 2025 14:02:07 +0200 Subject: [PATCH] feat: adding login/RegisterForm and new columns in PG --- app/login/page.tsx | 18 +- components/login/auth-wrapper.tsx | 64 +++++ components/login/index.ts | 3 + components/login/login-form.tsx | 117 +++++++++ components/login/register-form.tsx | 327 +++++++++++++++++++++++++ package.json | 10 +- pnpm-lock.yaml | 34 +++ scripts/init.sql | 6 +- scripts/migrations/add_auth_fields.sql | 22 ++ 9 files changed, 582 insertions(+), 19 deletions(-) create mode 100644 components/login/auth-wrapper.tsx create mode 100644 components/login/login-form.tsx create mode 100644 components/login/register-form.tsx create mode 100644 scripts/migrations/add_auth_fields.sql diff --git a/app/login/page.tsx b/app/login/page.tsx index a8dff13..2bdce7a 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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 ( - - - - ); + // 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 ( - + ); } catch (error) { diff --git a/components/login/auth-wrapper.tsx b/components/login/auth-wrapper.tsx new file mode 100644 index 0000000..ac56897 --- /dev/null +++ b/components/login/auth-wrapper.tsx @@ -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 ( + setIsLogin(false)} + loading={loading} + /> + ); + } + + return ( + setIsLogin(true)} + loading={loading} + /> + ); +} diff --git a/components/login/index.ts b/components/login/index.ts index a2e48cd..152aca8 100644 --- a/components/login/index.ts +++ b/components/login/index.ts @@ -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"; diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx new file mode 100644 index 0000000..c29e397 --- /dev/null +++ b/components/login/login-form.tsx @@ -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 ( +
+
+ + PeakSkills +
+ +

Connexion

+

+ Connectez-vous à votre compte PeakSkills +

+ +
+ + + Se connecter + + Entrez vos identifiants pour accéder à votre compte + + + +
+
+ + setEmail(e.target.value)} + placeholder="votre@email.com" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Votre mot de passe" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+ + +
+ +
+

+ Pas encore de compte ?{" "} + +

+
+
+
+
+
+ ); +} diff --git a/components/login/register-form.tsx b/components/login/register-form.tsx new file mode 100644 index 0000000..2f5495e --- /dev/null +++ b/components/login/register-form.tsx @@ -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(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); + }, [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 ( +
+
+ + PeakSkills +
+ +

Créer un compte

+

+ Rejoignez PeakSkills et commencez votre évaluation +

+ +
+ + + Inscription + + Créez votre compte pour commencer + + + +
+
+
+ + setFirstName(e.target.value)} + placeholder="Votre prénom" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+ +
+ + setLastName(e.target.value)} + placeholder="Votre nom" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+
+ +
+ + setEmail(e.target.value)} + placeholder="votre@email.com" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+ +
+
+ + setPassword(e.target.value)} + placeholder="Mot de passe" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirmer" + required + className="bg-white/10 border-white/20 text-white placeholder:text-slate-400" + /> +
+
+ +
+ +
+ + + {isTeamDropdownOpen && ( +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-8 h-8 text-sm bg-gray-700 border-white/20 text-white" + autoFocus + /> +
+
+ + {Object.entries(teamsByDirection).map( + ([direction, directionTeams]) => ( +
+
+ + {direction} +
+ {directionTeams.map((team) => ( + + ))} +
+ ) + )} + + {Object.keys(teamsByDirection).length === 0 && ( +
+ Aucune équipe trouvée pour "{searchTerm}" +
+ )} +
+ )} +
+
+ + +
+ +
+

+ Déjà un compte ?{" "} + +

+
+
+
+
+
+ ); +} diff --git a/package.json b/package.json index 84ea69f..66b1204 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977c2f5..b72e822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/init.sql b/scripts/init.sql index eda23f3..0b2a39e 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -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 diff --git a/scripts/migrations/add_auth_fields.sql b/scripts/migrations/add_auth_fields.sql new file mode 100644 index 0000000..fdbf97b --- /dev/null +++ b/scripts/migrations/add_auth_fields.sql @@ -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); +