diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index 98af8bc..fd96f43 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -17,6 +17,7 @@ import { TypeOrmInvitationTokenRepository } from '../../infrastructure/persisten import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity'; +import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity'; import { InvitationService } from '../services/invitation.service'; import { InvitationsController } from '../controllers/invitations.controller'; import { EmailModule } from '../../infrastructure/email/email.module'; @@ -40,7 +41,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; }), // 👇 Add this to register TypeORM repositories - TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity]), + TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]), // Email module for sending invitations EmailModule, diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index aa0c892..d5c0d18 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -5,10 +5,14 @@ import { Logger, Inject, BadRequestException, + NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; +import * as crypto from 'crypto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, IsNull } from 'typeorm'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { User, UserRole } from '@domain/entities/user.entity'; import { @@ -16,9 +20,11 @@ import { ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization } from '@domain/entities/organization.entity'; +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { v4 as uuidv4 } from 'uuid'; import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { SubscriptionService } from '../services/subscription.service'; +import { PasswordResetTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity'; export interface JwtPayload { sub: string; // user ID @@ -39,6 +45,10 @@ export class AuthService { private readonly userRepository: UserRepository, @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, + @Inject(EMAIL_PORT) + private readonly emailService: EmailPort, + @InjectRepository(PasswordResetTokenOrmEntity) + private readonly passwordResetTokenRepository: Repository, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly subscriptionService: SubscriptionService @@ -205,6 +215,85 @@ export class AuthService { } } + /** + * Initiate password reset — generates token and sends email + */ + async forgotPassword(email: string): Promise { + this.logger.log(`Password reset requested for: ${email}`); + + const user = await this.userRepository.findByEmail(email); + + // Silently succeed if user not found (security: don't reveal user existence) + if (!user || !user.isActive) { + return; + } + + // Invalidate any existing unused tokens for this user + await this.passwordResetTokenRepository.update( + { userId: user.id, usedAt: IsNull() }, + { usedAt: new Date() } + ); + + // Generate a secure random token + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await this.passwordResetTokenRepository.save({ + userId: user.id, + token, + expiresAt, + usedAt: null, + }); + + await this.emailService.sendPasswordResetEmail(email, token); + + this.logger.log(`Password reset email sent to: ${email}`); + } + + /** + * Reset password using token from email + */ + async resetPassword(token: string, newPassword: string): Promise { + const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } }); + + if (!resetToken) { + throw new BadRequestException('Token de réinitialisation invalide ou expiré'); + } + + if (resetToken.usedAt) { + throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé'); + } + + if (resetToken.expiresAt < new Date()) { + throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'); + } + + const user = await this.userRepository.findById(resetToken.userId); + + if (!user || !user.isActive) { + throw new NotFoundException('Utilisateur introuvable'); + } + + const passwordHash = await argon2.hash(newPassword, { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, + }); + + // Update password (mutates in place) + user.updatePassword(passwordHash); + await this.userRepository.save(user); + + // Mark token as used + await this.passwordResetTokenRepository.update( + { id: resetToken.id }, + { usedAt: new Date() } + ); + + this.logger.log(`Password reset successfully for user: ${user.email}`); + } + /** * Validate user from JWT payload */ @@ -336,6 +425,7 @@ export class AuthService { type: organizationData.type, scac: organizationData.scac, siren: organizationData.siren, + siret: organizationData.siret, address: { street: organizationData.street, city: organizationData.city, diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index 256490c..fda3c3f 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -11,7 +11,14 @@ import { } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { AuthService } from '../auth/auth.service'; -import { LoginDto, RegisterDto, AuthResponseDto, RefreshTokenDto } from '../dto/auth-login.dto'; +import { + LoginDto, + RegisterDto, + AuthResponseDto, + RefreshTokenDto, + ForgotPasswordDto, + ResetPasswordDto, +} from '../dto/auth-login.dto'; import { Public } from '../decorators/public.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -209,6 +216,41 @@ export class AuthController { return { message: 'Logout successful' }; } + /** + * Forgot password — sends reset email + */ + @Public() + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Forgot password', + description: 'Send a password reset email. Always returns 200 to avoid user enumeration.', + }) + @ApiResponse({ status: 200, description: 'Reset email sent (if account exists)' }) + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> { + await this.authService.forgotPassword(dto.email); + return { + message: 'Si un compte existe avec cet email, vous recevrez un lien de réinitialisation.', + }; + } + + /** + * Reset password using token from email + */ + @Public() + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Reset password', + description: 'Reset user password using the token received by email.', + }) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> { + await this.authService.resetPassword(dto.token, dto.newPassword); + return { message: 'Mot de passe réinitialisé avec succès.' }; + } + /** * Get current user profile * diff --git a/apps/backend/src/application/dto/auth-login.dto.ts b/apps/backend/src/application/dto/auth-login.dto.ts index c947601..26be29a 100644 --- a/apps/backend/src/application/dto/auth-login.dto.ts +++ b/apps/backend/src/application/dto/auth-login.dto.ts @@ -7,6 +7,7 @@ import { IsEnum, MaxLength, Matches, + IsBoolean, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -22,12 +23,45 @@ export class LoginDto { @ApiProperty({ example: 'SecurePassword123!', - description: 'Password (minimum 12 characters)', + description: 'Password', + }) + @IsString() + password: string; + + @ApiPropertyOptional({ + example: true, + description: 'Remember me for extended session', + }) + @IsBoolean() + @IsOptional() + rememberMe?: boolean; +} + +export class ForgotPasswordDto { + @ApiProperty({ + example: 'john.doe@acme.com', + description: 'Email address for password reset', + }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; +} + +export class ResetPasswordDto { + @ApiProperty({ + example: 'abc123token...', + description: 'Password reset token from email', + }) + @IsString() + token: string; + + @ApiProperty({ + example: 'NewSecurePassword123!', + description: 'New password (minimum 12 characters)', minLength: 12, }) @IsString() @MinLength(12, { message: 'Password must be at least 12 characters' }) - password: string; + newPassword: string; } /** @@ -106,6 +140,19 @@ export class RegisterOrganizationDto { @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) siren: string; + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits, optional)', + minLength: 14, + maxLength: 14, + }) + @IsString() + @IsOptional() + @MinLength(14, { message: 'SIRET must be exactly 14 digits' }) + @MaxLength(14, { message: 'SIRET must be exactly 14 digits' }) + @Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' }) + siret?: string; + @ApiPropertyOptional({ example: 'MAEU', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts new file mode 100644 index 0000000..fd4598f --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('password_reset_tokens') +export class PasswordResetTokenOrmEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + @Index('IDX_password_reset_tokens_user_id') + userId: string; + + @Column({ unique: true, length: 255 }) + @Index('IDX_password_reset_tokens_token') + token: string; + + @Column({ name: 'expires_at', type: 'timestamp' }) + expiresAt: Date; + + @Column({ name: 'used_at', type: 'timestamp', nullable: true }) + usedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741500000001-CreatePasswordResetTokens.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741500000001-CreatePasswordResetTokens.ts new file mode 100644 index 0000000..af57244 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741500000001-CreatePasswordResetTokens.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePasswordResetTokens1741500000001 implements MigrationInterface { + name = 'CreatePasswordResetTokens1741500000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "password_reset_tokens" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "token" character varying(255) NOT NULL, + "expires_at" TIMESTAMP NOT NULL, + "used_at" TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_password_reset_tokens" PRIMARY KEY ("id"), + CONSTRAINT "UQ_password_reset_tokens_token" UNIQUE ("token") + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_password_reset_tokens_token" ON "password_reset_tokens" ("token")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_password_reset_tokens_user_id" ON "password_reset_tokens" ("user_id")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "password_reset_tokens"`); + } +} diff --git a/apps/frontend/app/forgot-password/page.tsx b/apps/frontend/app/forgot-password/page.tsx index 479e187..0830aac 100644 --- a/apps/frontend/app/forgot-password/page.tsx +++ b/apps/frontend/app/forgot-password/page.tsx @@ -1,13 +1,9 @@ -/** - * Forgot Password Page - * - * Request password reset - */ - 'use client'; import { useState } from 'react'; import Link from 'next/link'; +import Image from 'next/image'; +import { forgotPassword } from '@/lib/api/auth'; export default function ForgotPasswordPage() { const [email, setEmail] = useState(''); @@ -21,97 +17,173 @@ export default function ForgotPasswordPage() { setLoading(true); try { - // TODO: Implement forgotPassword API endpoint - await new Promise(resolve => setTimeout(resolve, 1000)); + await forgotPassword(email); setSuccess(true); } catch (err: any) { - setError(err.response?.data?.message || 'Failed to send reset email. Please try again.'); + setError(err.message || 'Une erreur est survenue. Veuillez réessayer.'); } finally { setLoading(false); } }; - if (success) { - return ( -
-
-
-

Xpeditis

-

- Check your email -

-
- -
-
- We've sent a password reset link to {email}. Please check your inbox - and follow the instructions. -
-
- -
- - Back to sign in + return ( +
+ {/* Left Side - Form */} +
+
+ {/* Logo */} +
+ + Xpeditis
+ + {success ? ( + <> +
+
+ + + +
+

Email envoyé

+

+ Si un compte est associé à {email}, vous recevrez un email avec + les instructions pour réinitialiser votre mot de passe. +

+

+ Pensez à vérifier vos spams si vous ne recevez rien d'ici quelques minutes. +

+
+ + Retour Ă  la connexion + + + ) : ( + <> + {/* Header */} +
+

Mot de passe oublié ?

+

+ Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe. +

+
+ + {/* Error Message */} + {error && ( +
+ + + +

{error}

+
+ )} + + {/* Form */} +
+
+ + setEmail(e.target.value)} + className="input w-full" + placeholder="votre.email@entreprise.com" + autoComplete="email" + disabled={loading} + /> +
+ + +
+ +
+ + + + + Retour Ă  la connexion + +
+ + )} + + {/* Footer Links */} +
+
+ + Contactez-nous + + + Confidentialité + + + Conditions + +
+
- ); - } - return ( -
-
-
-

Xpeditis

-

- Reset your password -

-

- Enter your email address and we'll send you a link to reset your password. -

-
- -
- {error && ( -
-
{error}
+ {/* Right Side - Brand */} +
+
+
+
+

Sécurité avant tout

+

+ La protection de votre compte est notre priorité. Réinitialisez votre mot de passe en toute sécurité. +

+
+
+
+ + + +
+
+

Lien sécurisé

+

Le lien expire après 1 heure pour votre sécurité

+
+
+
+
+ + + +
+
+

Email de confirmation

+

Vérifiez votre boîte de réception et vos spams

+
+
- )} - -
- - setEmail(e.target.value)} - className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" - placeholder="Email address" - />
- -
- -
- -
- - Back to sign in - -
- +
+
+ + + + + +
); diff --git a/apps/frontend/app/login/page.tsx b/apps/frontend/app/login/page.tsx index d06a37d..11dc16d 100644 --- a/apps/frontend/app/login/page.tsx +++ b/apps/frontend/app/login/page.tsx @@ -129,7 +129,7 @@ function LoginPageContent() { setIsLoading(true); try { - await login(email, password, redirectTo); + await login(email, password, redirectTo, rememberMe); // Navigation is handled by the login function in auth context } catch (err: any) { const { message, field } = getErrorMessage(err); @@ -308,9 +308,6 @@ function LoginPageContent() { {/* Footer Links */}
- - Centre d'aide - Contactez-nous diff --git a/apps/frontend/app/register/page.tsx b/apps/frontend/app/register/page.tsx index bed9e40..e00eebd 100644 --- a/apps/frontend/app/register/page.tsx +++ b/apps/frontend/app/register/page.tsx @@ -1,12 +1,6 @@ -/** - * Register Page - Xpeditis - * - * Modern registration page with split-screen design - */ - 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; @@ -14,20 +8,25 @@ import { register } from '@/lib/api'; import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; import type { OrganizationType } from '@/types/api'; -export default function RegisterPage() { +function RegisterPageContent() { const router = useRouter(); const searchParams = useSearchParams(); + // Step management + const [step, setStep] = useState<1 | 2>(1); + + // Step 1 — Personal info const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - // Organization fields + // Step 2 — Organization const [organizationName, setOrganizationName] = useState(''); const [organizationType, setOrganizationType] = useState('FREIGHT_FORWARDER'); const [siren, setSiren] = useState(''); + const [siret, setSiret] = useState(''); const [street, setStreet] = useState(''); const [city, setCity] = useState(''); const [state, setState] = useState(''); @@ -37,12 +36,11 @@ export default function RegisterPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - // Invitation-related state + // Invitation state const [invitationToken, setInvitationToken] = useState(null); const [invitation, setInvitation] = useState(null); const [isVerifyingInvitation, setIsVerifyingInvitation] = useState(false); - // Verify invitation token on mount useEffect(() => { const token = searchParams.get('token'); if (token) { @@ -51,13 +49,12 @@ export default function RegisterPage() { .then(invitationData => { setInvitation(invitationData); setInvitationToken(token); - // Pre-fill user information from invitation setEmail(invitationData.email); setFirstName(invitationData.firstName); setLastName(invitationData.lastName); }) - .catch(err => { - setError('Le lien d\'invitation est invalide ou expiré.'); + .catch(() => { + setError("Le lien d'invitation est invalide ou expiré."); }) .finally(() => { setIsVerifyingInvitation(false); @@ -65,41 +62,58 @@ export default function RegisterPage() { } }, [searchParams]); - const handleSubmit = async (e: React.FormEvent) => { + // ---- Step 1 validation ---- + const validateStep1 = (): string | null => { + if (!firstName.trim() || firstName.trim().length < 2) return 'Le prénom doit contenir au moins 2 caractères'; + if (!lastName.trim() || lastName.trim().length < 2) return 'Le nom doit contenir au moins 2 caractères'; + if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "L'adresse email n'est pas valide"; + if (password.length < 12) return 'Le mot de passe doit contenir au moins 12 caractères'; + if (password !== confirmPassword) return 'Les mots de passe ne correspondent pas'; + return null; + }; + + const handleStep1 = (e: React.FormEvent) => { e.preventDefault(); setError(''); - - // Validate passwords match - if (password !== confirmPassword) { - setError('Les mots de passe ne correspondent pas'); + const err = validateStep1(); + if (err) { + setError(err); return; } + // If invitation — submit directly (no org step) + if (invitationToken) { + handleFinalSubmit(); + } else { + setStep(2); + } + }; - // Validate password length - if (password.length < 12) { - setError('Le mot de passe doit contenir au moins 12 caractères'); + // ---- Step 2 validation ---- + const validateStep2 = (): string | null => { + if (!organizationName.trim()) return "Le nom de l'organisation est requis"; + if (!/^[0-9]{9}$/.test(siren)) return 'Le numéro SIREN est requis (9 chiffres)'; + if (siret && !/^[0-9]{14}$/.test(siret)) return 'Le numéro SIRET doit contenir 14 chiffres'; + if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { + return "Tous les champs d'adresse sont requis"; + } + return null; + }; + + const handleStep2 = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + const err = validateStep2(); + if (err) { + setError(err); return; } + handleFinalSubmit(); + }; - // Validate organization fields only if NOT using invitation - if (!invitationToken) { - if (!organizationName.trim()) { - setError('Le nom de l\'organisation est requis'); - return; - } - - if (!siren.trim() || !/^[0-9]{9}$/.test(siren)) { - setError('Le numero SIREN est requis (9 chiffres)'); - return; - } - - if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { - setError('Tous les champs d\'adresse sont requis'); - return; - } - } - + // ---- Final submit ---- + const handleFinalSubmit = async () => { setIsLoading(true); + setError(''); try { await register({ @@ -107,7 +121,6 @@ export default function RegisterPage() { password, firstName, lastName, - // If invitation token exists, use it; otherwise provide organization data ...(invitationToken ? { invitationToken } : { @@ -115,6 +128,7 @@ export default function RegisterPage() { name: organizationName, type: organizationType, siren, + siret: siret || undefined, street, city, state: state || undefined, @@ -126,18 +140,92 @@ export default function RegisterPage() { router.push('/dashboard'); } catch (err: any) { setError(err.message || 'Erreur lors de la création du compte'); + // On error at step 2, stay on step 2; at invitation (step 1), stay on step 1 } finally { setIsLoading(false); } }; + // ---- Right panel content ---- + const rightPanel = ( +
+
+
+
+

+ {invitation ? 'Rejoignez votre équipe' : 'Rejoignez des milliers d\'entreprises'} +

+

+ Simplifiez votre logistique maritime et gagnez du temps sur chaque expédition. +

+
+
+
+ + + +
+
+

Essai gratuit de 30 jours

+

Testez toutes les fonctionnalités sans engagement

+
+
+
+
+ + + +
+
+

Sécurité maximale

+

Vos données sont protégées et chiffrées

+
+
+
+
+ + + +
+
+

Support 24/7

+

Notre équipe est là pour vous accompagner

+
+
+
+
+
+
2k+
+
Entreprises
+
+
+
150+
+
Pays couverts
+
+
+
24/7
+
Support
+
+
+
+
+
+ + + + + +
+
+ ); + return (
{/* Left Side - Form */}
{/* Logo */} -
+
+ {/* Progress indicator (only for self-registration, 2 steps) */} + {!invitation && ( +
+
+
+
= 1 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400' + }`}> + {step > 1 ? ( + + + + ) : '1'} +
+ = 1 ? 'text-brand-navy' : 'text-neutral-400'}`}> + Votre compte + +
+
= 2 ? 'bg-brand-navy' : 'bg-neutral-200'}`} /> +
+
= 2 ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-400' + }`}> + 2 +
+ = 2 ? 'text-brand-navy' : 'text-neutral-400'}`}> + Votre organisation + +
+
+
+ )} + {/* Header */} -
-

- {invitation ? 'Accepter l\'invitation' : 'Créer un compte'} -

-

- {invitation - ? `Vous avez été invité à rejoindre une organisation` - : 'Commencez votre essai gratuit dès aujourd\'hui'} -

+
+ {isVerifyingInvitation ? ( +

Vérification de l'invitation...

+ ) : invitation ? ( + <> +

Accepter l'invitation

+
+

+ Invitation valide — créez votre mot de passe pour rejoindre l'organisation. +

+
+ + ) : step === 1 ? ( + <> +

Créer un compte

+

Commencez votre essai gratuit dès aujourd'hui

+ + ) : ( + <> +

Votre organisation

+

Renseignez les informations de votre entreprise

+ + )}
- {/* Verifying Invitation Loading */} - {isVerifyingInvitation && ( -
-

Vérification de l'invitation...

-
- )} - - {/* Success Message for Invitation */} - {invitation && !error && ( -
-

- Invitation valide ! Créez votre mot de passe pour rejoindre l'organisation. -

-
- )} - {/* Error Message */} {error && ( -
+
+ + +

{error}

)} - {/* Form */} -
- {/* First Name & Last Name */} -
+ {/* ---- STEP 1: Personal info ---- */} + {(step === 1 || invitation) && !isVerifyingInvitation && ( + +
+
+ + setFirstName(e.target.value)} + className="input w-full" + placeholder="Jean" + disabled={isLoading || !!invitation} + /> +
+
+ + setLastName(e.target.value)} + className="input w-full" + placeholder="Dupont" + disabled={isLoading || !!invitation} + /> +
+
+
- + setFirstName(e.target.value)} + value={email} + onChange={e => setEmail(e.target.value)} className="input w-full" - placeholder="Jean" + placeholder="jean.dupont@entreprise.com" + autoComplete="email" disabled={isLoading || !!invitation} />
+
- + setLastName(e.target.value)} + value={password} + onChange={e => setPassword(e.target.value)} className="input w-full" - placeholder="Dupont" - disabled={isLoading || !!invitation} + placeholder="••••••••••••" + autoComplete="new-password" + disabled={isLoading} + /> +

Au moins 12 caractères

+
+ +
+ + setConfirmPassword(e.target.value)} + className="input w-full" + placeholder="••••••••••••" + autoComplete="new-password" + disabled={isLoading} />
-
- {/* Email */} -
- - setEmail(e.target.value)} - className="input w-full" - placeholder="jean.dupont@entreprise.com" - autoComplete="email" - disabled={isLoading || !!invitation} - /> -
- - {/* Password */} -
- - setPassword(e.target.value)} - className="input w-full" - placeholder="••••••••••••" - autoComplete="new-password" +
+ className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed mt-2" + > + {isLoading + ? 'Création du compte...' + : invitation + ? 'Créer mon compte' + : 'Continuer'} + - {/* Confirm Password */} -
- - setConfirmPassword(e.target.value)} - className="input w-full" - placeholder="••••••••••••" - autoComplete="new-password" - disabled={isLoading} - /> -
+

+ En créant un compte, vous acceptez nos{' '} + Conditions d'utilisation{' '} + et notre{' '} + Politique de confidentialité +

+
+ )} - {/* Organization Section - Only show if NOT using invitation */} - {!invitation && ( -
-

Informations de votre organisation

- - {/* Organization Name */} -
- + {/* ---- STEP 2: Organization info ---- */} + {step === 2 && !invitation && ( +
+
+
- {/* Organization Type */} -
- +
+ setSiren(e.target.value.replace(/\D/g, ''))} - className="input w-full" - placeholder="123456789" - maxLength={9} - disabled={isLoading} - /> -

9 chiffres, obligatoire pour toute organisation

+
+
+ + setSiren(e.target.value.replace(/\D/g, '').slice(0, 9))} + className="input w-full" + placeholder="123456789" + maxLength={9} + disabled={isLoading} + /> +

9 chiffres

+
+
+ + setSiret(e.target.value.replace(/\D/g, '').slice(0, 14))} + className="input w-full" + placeholder="12345678900014" + maxLength={14} + disabled={isLoading} + /> +

14 chiffres

+
- {/* Street Address */} -
- +
+
- {/* City & Postal Code */} -
+
- +
- +
- {/* State & Country */}
- + setCountry(e.target.value)} + onChange={e => setCountry(e.target.value.toUpperCase().slice(0, 2))} className="input w-full" placeholder="FR" maxLength={2} disabled={isLoading} /> +

Code ISO 2 lettres

+ +
+ +
- )} - {/* Submit Button */} - - - {/* Terms */} -

- En créant un compte, vous acceptez nos{' '} - - Conditions d'utilisation - {' '} - et notre{' '} - - Politique de confidentialité - -

- +

+ En créant un compte, vous acceptez nos{' '} + Conditions d'utilisation{' '} + et notre{' '} + Politique de confidentialité +

+ + )} {/* Sign In Link */}

Vous avez déjà un compte ?{' '} - - Se connecter - + Se connecter

{/* Footer Links */} -
+
- - Centre d'aide - - - Contactez-nous - - - Confidentialité - - - Conditions - + Contactez-nous + Confidentialité + Conditions
- {/* Right Side - Brand Features (same as login) */} -
-
-
-
-

- Rejoignez des milliers d'entreprises -

-

- Simplifiez votre logistique maritime et gagnez du temps sur chaque expédition. -

- -
-
-
- - - -
-
-

Essai gratuit de 30 jours

-

- Testez toutes les fonctionnalités sans engagement -

-
-
- -
-
- - - -
-
-

Sécurité maximale

-

- Vos données sont protégées et chiffrées -

-
-
- -
-
- - - -
-
-

Support 24/7

-

- Notre équipe est là pour vous accompagner -

-
-
-
- -
-
-
2k+
-
Entreprises
-
-
-
150+
-
Pays couverts
-
-
-
24/7
-
Support
-
-
-
-
- -
- - - - - -
-
+ {rightPanel}
); } + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/apps/frontend/app/reset-password/page.tsx b/apps/frontend/app/reset-password/page.tsx index 0308932..0811cfb 100644 --- a/apps/frontend/app/reset-password/page.tsx +++ b/apps/frontend/app/reset-password/page.tsx @@ -1,16 +1,12 @@ -/** - * Reset Password Page - * - * Reset password with token from email - */ - 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Link from 'next/link'; +import Image from 'next/image'; +import { resetPassword } from '@/lib/api/auth'; -export default function ResetPasswordPage() { +function ResetPasswordContent() { const searchParams = useSearchParams(); const router = useRouter(); const [token, setToken] = useState(''); @@ -19,13 +15,14 @@ export default function ResetPasswordPage() { const [success, setSuccess] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [tokenError, setTokenError] = useState(false); useEffect(() => { const tokenFromUrl = searchParams.get('token'); if (tokenFromUrl) { setToken(tokenFromUrl); } else { - setError('Invalid reset link. Please request a new password reset.'); + setTokenError(true); } }, [searchParams]); @@ -33,139 +30,218 @@ export default function ResetPasswordPage() { e.preventDefault(); setError(''); - // Validate passwords match if (password !== confirmPassword) { - setError('Passwords do not match'); + setError('Les mots de passe ne correspondent pas'); return; } - // Validate password length if (password.length < 12) { - setError('Password must be at least 12 characters long'); - return; - } - - if (!token) { - setError('Invalid reset token'); + setError('Le mot de passe doit contenir au moins 12 caractères'); return; } setLoading(true); try { - // TODO: Implement resetPassword API endpoint - await new Promise(resolve => setTimeout(resolve, 1000)); + await resetPassword(token, password); setSuccess(true); - setTimeout(() => { - router.push('/login'); - }, 3000); + setTimeout(() => router.push('/login'), 3000); } catch (err: any) { - setError( - err.response?.data?.message || 'Failed to reset password. The link may have expired.' - ); + setError(err.message || 'Le lien de réinitialisation est invalide ou expiré.'); } finally { setLoading(false); } }; - if (success) { - return ( -
-
-
-

Xpeditis

-

- Password reset successful -

-
- -
-
- Your password has been reset successfully. You will be redirected to the login page in - a few seconds... -
-
- -
- - Go to login now + return ( +
+ {/* Left Side - Form */} +
+
+ {/* Logo */} +
+ + Xpeditis
+ + {tokenError ? ( + <> +
+
+ + + +
+

Lien invalide

+

+ Ce lien de réinitialisation est invalide. Veuillez faire une nouvelle demande. +

+
+ + Demander un nouveau lien + + + ) : success ? ( + <> +
+
+ + + +
+

Mot de passe réinitialisé !

+

+ Votre mot de passe a été modifié avec succès. Vous allez être redirigé vers la page de connexion... +

+
+ + Se connecter maintenant + + + ) : ( + <> + {/* Header */} +
+

Nouveau mot de passe

+

+ Choisissez un nouveau mot de passe sécurisé pour votre compte. +

+
+ + {/* Error Message */} + {error && ( +
+ + + +

{error}

+
+ )} + + {/* Form */} +
+
+ + setPassword(e.target.value)} + className="input w-full" + placeholder="••••••••••••" + autoComplete="new-password" + disabled={loading} + /> +

Au moins 12 caractères

+
+ +
+ + setConfirmPassword(e.target.value)} + className="input w-full" + placeholder="••••••••••••" + autoComplete="new-password" + disabled={loading} + /> +
+ + +
+ +
+ + + + + Retour Ă  la connexion + +
+ + )} + + {/* Footer Links */} +
+
+ + Contactez-nous + + + Confidentialité + + + Conditions + +
+
- ); - } - return ( -
-
-
-

Xpeditis

-

- Set new password -

-

Please enter your new password.

+ {/* Right Side - Brand */} +
+
+
+
+

Votre sécurité, notre priorité

+

+ Choisissez un mot de passe fort pour protéger votre compte et vos données. +

+
+ {[ + 'Au moins 12 caractères', + 'Mélangez lettres, chiffres et symboles', + 'Évitez les mots du dictionnaire', + 'N\'utilisez pas le même mot de passe ailleurs', + ].map((tip) => ( +
+ + + +

{tip}

+
+ ))} +
+
+
+
+ + + + +
- -
- {error && ( -
-
{error}
-
- )} - -
-
- - setPassword(e.target.value)} - className="mt-1 appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - /> -

Must be at least 12 characters long

-
- -
- - setConfirmPassword(e.target.value)} - className="mt-1 appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - /> -
-
- -
- -
- -
- - Back to sign in - -
-
); } + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/lib/api/auth.ts b/apps/frontend/src/lib/api/auth.ts index 3ffe267..4470c10 100644 --- a/apps/frontend/src/lib/api/auth.ts +++ b/apps/frontend/src/lib/api/auth.ts @@ -31,11 +31,12 @@ export async function register(data: RegisterRequest): Promise { * User login * POST /api/v1/auth/login */ -export async function login(data: LoginRequest): Promise { - const response = await post('/api/v1/auth/login', data, false); +export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise { + const { rememberMe, ...loginData } = data; + const response = await post('/api/v1/auth/login', loginData, false); - // Store tokens - setAuthTokens(response.accessToken, response.refreshToken); + // Store tokens — localStorage if rememberMe, sessionStorage otherwise + setAuthTokens(response.accessToken, response.refreshToken, rememberMe ?? false); return response; } @@ -69,3 +70,19 @@ export async function logout(): Promise { export async function getCurrentUser(): Promise { return get('/api/v1/auth/me'); } + +/** + * Forgot password — request reset email + * POST /api/v1/auth/forgot-password + */ +export async function forgotPassword(email: string): Promise<{ message: string }> { + return post<{ message: string }>('/api/v1/auth/forgot-password', { email }, false); +} + +/** + * Reset password with token from email + * POST /api/v1/auth/reset-password + */ +export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { + return post<{ message: string }>('/api/v1/auth/reset-password', { token, newPassword }, false); +} diff --git a/apps/frontend/src/lib/api/client.ts b/apps/frontend/src/lib/api/client.ts index c63a63c..2fe11f0 100644 --- a/apps/frontend/src/lib/api/client.ts +++ b/apps/frontend/src/lib/api/client.ts @@ -11,40 +11,46 @@ let isRefreshing = false; let refreshSubscribers: Array<(token: string) => void> = []; /** - * Get authentication token from localStorage + * Get authentication token — checks localStorage first (remember me), then sessionStorage */ export function getAuthToken(): string | null { if (typeof window === 'undefined') return null; - return localStorage.getItem('access_token'); + return localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); } /** - * Get refresh token from localStorage + * Get refresh token — checks localStorage first (remember me), then sessionStorage */ export function getRefreshToken(): string | null { if (typeof window === 'undefined') return null; - return localStorage.getItem('refresh_token'); + return localStorage.getItem('refresh_token') || sessionStorage.getItem('refresh_token'); } /** - * Set authentication tokens + * Set authentication tokens. + * rememberMe=true → localStorage (persists across browser sessions) + * rememberMe=false → sessionStorage (cleared when browser closes) */ -export function setAuthTokens(accessToken: string, refreshToken: string): void { +export function setAuthTokens(accessToken: string, refreshToken: string, rememberMe = false): void { if (typeof window === 'undefined') return; - localStorage.setItem('access_token', accessToken); - localStorage.setItem('refresh_token', refreshToken); + const storage = rememberMe ? localStorage : sessionStorage; + storage.setItem('access_token', accessToken); + storage.setItem('refresh_token', refreshToken); // Sync to cookie so Next.js middleware can read it for route protection document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`; } /** - * Clear authentication tokens + * Clear authentication tokens from both storages */ export function clearAuthTokens(): void { if (typeof window === 'undefined') return; localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('user'); + sessionStorage.removeItem('access_token'); + sessionStorage.removeItem('refresh_token'); + sessionStorage.removeItem('user'); // Expire the middleware cookie document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; } @@ -95,9 +101,10 @@ async function refreshAccessToken(): Promise { const data = await response.json(); const newAccessToken = data.accessToken; - // Update access token in localStorage and cookie (keep same refresh token) + // Update access token in the same storage that holds the refresh token if (typeof window !== 'undefined') { - localStorage.setItem('access_token', newAccessToken); + const storage = localStorage.getItem('refresh_token') ? localStorage : sessionStorage; + storage.setItem('access_token', newAccessToken); document.cookie = `accessToken=${newAccessToken}; path=/; SameSite=Lax`; } diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index f3d6e82..6ee2f25 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -24,8 +24,8 @@ export { ApiError, } from './client'; -// Authentication (5 endpoints) -export { register, login, refreshToken, logout, getCurrentUser } from './auth'; +// Authentication (7 endpoints) +export { register, login, refreshToken, logout, getCurrentUser, forgotPassword, resetPassword } from './auth'; // Rates (4 endpoints) export { searchRates, searchCsvRates, getAvailableCompanies, getFilterOptions } from './rates'; diff --git a/apps/frontend/src/lib/context/auth-context.tsx b/apps/frontend/src/lib/context/auth-context.tsx index 205cdc6..6849b80 100644 --- a/apps/frontend/src/lib/context/auth-context.tsx +++ b/apps/frontend/src/lib/context/auth-context.tsx @@ -20,7 +20,7 @@ import type { UserPayload } from '@/types/api'; interface AuthContextType { user: UserPayload | null; loading: boolean; - login: (email: string, password: string, redirectTo?: string) => Promise; + login: (email: string, password: string, redirectTo?: string, rememberMe?: boolean) => Promise; register: (data: { email: string; password: string; @@ -106,15 +106,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => clearInterval(tokenCheckInterval); }, []); - const login = async (email: string, password: string, redirectTo = '/dashboard') => { + const login = async (email: string, password: string, redirectTo = '/dashboard', rememberMe = false) => { try { - await apiLogin({ email, password }); + await apiLogin({ email, password, rememberMe }); // Fetch complete user profile after login const currentUser = await getCurrentUser(); setUser(currentUser); - // Store user in localStorage + // Store user in the same storage as the tokens if (typeof window !== 'undefined') { - localStorage.setItem('user', JSON.stringify(currentUser)); + const storage = rememberMe ? localStorage : sessionStorage; + storage.setItem('user', JSON.stringify(currentUser)); } router.push(redirectTo); } catch (error) { diff --git a/apps/frontend/src/types/api.ts b/apps/frontend/src/types/api.ts index 12543d1..b9ddcef 100644 --- a/apps/frontend/src/types/api.ts +++ b/apps/frontend/src/types/api.ts @@ -12,6 +12,7 @@ export interface RegisterOrganizationData { name: string; type: OrganizationType; siren: string; + siret?: string; street: string; city: string; state?: string; @@ -25,7 +26,8 @@ export interface RegisterRequest { password: string; firstName: string; lastName: string; - organizationId?: string; // For invited users + invitationToken?: string; // For invited users (token-based) + organizationId?: string; // For invited users (ID-based) organization?: RegisterOrganizationData; // For new users }