Système pour rester connecté
This commit is contained in:
parent
ecc84d9652
commit
ecc8488541
13 changed files with 1872 additions and 34 deletions
333
REMEMBER_ME_FEATURE.md
Normal file
333
REMEMBER_ME_FEATURE.md
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
# Feature : "Rester connecté" (Remember Me)
|
||||||
|
|
||||||
|
**Date de création** : 16 octobre 2025
|
||||||
|
|
||||||
|
## 📋 Vue d'ensemble
|
||||||
|
|
||||||
|
Implémentation d'une fonctionnalité "Rester connecté pendant 30 jours" qui permet aux utilisateurs de maintenir leur session active même après la fermeture du navigateur.
|
||||||
|
|
||||||
|
## 🎯 Objectif
|
||||||
|
|
||||||
|
Résoudre le problème où les utilisateurs devaient se reconnecter à chaque fois qu'ils fermaient leur navigateur. Cette fonctionnalité permet d'améliorer l'expérience utilisateur tout en laissant le choix et en sensibilisant aux risques de sécurité.
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
|
### 1. Checkbox "Rester connecté"
|
||||||
|
- Disponible sur les deux modes d'authentification : **Mot de passe** et **Code par e-mail**
|
||||||
|
- Texte : "Rester connecté pendant 30 jours"
|
||||||
|
- Option opt-in (non cochée par défaut pour la sécurité)
|
||||||
|
|
||||||
|
### 2. Mini-card d'avertissement
|
||||||
|
- S'affiche uniquement quand la checkbox est cochée
|
||||||
|
- Message : "Recommandé uniquement sur un ordinateur non partagé"
|
||||||
|
- Icône d'alerte pour attirer l'attention
|
||||||
|
- Bouton "Pourquoi ?" pour plus d'informations
|
||||||
|
|
||||||
|
### 3. Modal explicatif
|
||||||
|
- Titre : "À propos de 'Rester connecté'"
|
||||||
|
- Sections :
|
||||||
|
- ✅ **Avantages** : Connexion automatique, gain de temps, sécurité maintenue
|
||||||
|
- ❌ **Risques sur ordinateur partagé** : Accès non autorisé, données sensibles exposées
|
||||||
|
- ⚠️ **Nos recommandations** : Quand cocher / ne pas cocher
|
||||||
|
- 🛡️ **Note de sécurité** : Rappel de toujours se déconnecter sur ordinateur partagé
|
||||||
|
|
||||||
|
### 4. Cookies persistants (30 jours)
|
||||||
|
- Si cochée : Les cookies Supabase sont définis avec `maxAge: 30 jours`
|
||||||
|
- Si non cochée : Cookies de session (supprimés à la fermeture du navigateur)
|
||||||
|
- Cookie `remember_me` pour tracer la préférence utilisateur
|
||||||
|
|
||||||
|
## 🏗️ Architecture technique
|
||||||
|
|
||||||
|
### Composants créés
|
||||||
|
|
||||||
|
#### 1. `components/auth/RememberMeInfoModal.tsx`
|
||||||
|
Modal React réutilisable avec :
|
||||||
|
- Props : `isOpen`, `onClose`
|
||||||
|
- Design moderne avec animations
|
||||||
|
- Icônes Lucide pour illustrer les sections
|
||||||
|
- Responsive mobile
|
||||||
|
|
||||||
|
#### 2. `components/auth/RememberMeInfoModal.module.css`
|
||||||
|
Styles dédiés au modal :
|
||||||
|
- Overlay avec backdrop-filter
|
||||||
|
- Animations (fadeIn, slideUp, slideIn)
|
||||||
|
- Sections colorées (vert, rouge, orange)
|
||||||
|
- Design adaptatif
|
||||||
|
|
||||||
|
### Fichiers modifiés
|
||||||
|
|
||||||
|
#### 1. `app/signin/page.tsx`
|
||||||
|
**Modifications :**
|
||||||
|
```typescript
|
||||||
|
// État ajouté
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
const [showRememberMeModal, setShowRememberMeModal] = useState(false);
|
||||||
|
|
||||||
|
// Import du modal
|
||||||
|
import RememberMeInfoModal from "@/components/auth/RememberMeInfoModal";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
// Envoi dans les requêtes API
|
||||||
|
body: JSON.stringify({ email, password, rememberMe })
|
||||||
|
body: JSON.stringify({ email, code, rememberMe })
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI ajoutée :**
|
||||||
|
- Checkbox avec label stylisé
|
||||||
|
- Mini-card d'avertissement conditionnelle
|
||||||
|
- Bouton "Pourquoi ?" qui ouvre le modal
|
||||||
|
- Modal en fin de JSX
|
||||||
|
|
||||||
|
#### 2. `app/signin/signin.module.css`
|
||||||
|
**Styles ajoutés :**
|
||||||
|
```css
|
||||||
|
.rememberMeSection { /* Container */ }
|
||||||
|
.rememberMeLabel { /* Label de la checkbox */ }
|
||||||
|
.rememberMeCheckbox { /* Input checkbox stylisé */ }
|
||||||
|
.rememberMeText { /* Texte "Rester connecté..." */ }
|
||||||
|
.rememberMeWarning { /* Mini-card d'avertissement */ }
|
||||||
|
.warningIcon { /* Icône AlertCircle */ }
|
||||||
|
.warningText { /* Texte d'avertissement */ }
|
||||||
|
.whyButton { /* Bouton "Pourquoi ?" */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `app/api/auth/signin-password/route.ts`
|
||||||
|
**Modifications :**
|
||||||
|
```typescript
|
||||||
|
// Extraction du paramètre
|
||||||
|
const { email, password, mfaCode, rememberMe } = await req.json();
|
||||||
|
|
||||||
|
// Gestion des cookies persistants
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
if (rememberMe) {
|
||||||
|
// Cookie remember_me pour le middleware
|
||||||
|
response.cookies.set("remember_me", "true", {
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 jours
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prolonger tous les cookies Supabase (sb-*)
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const allCookies = cookieStore.getAll();
|
||||||
|
|
||||||
|
allCookies.forEach((cookie) => {
|
||||||
|
if (cookie.name.startsWith("sb-")) {
|
||||||
|
response.cookies.set(cookie.name, cookie.value, {
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Supprimer remember_me si non coché
|
||||||
|
response.cookies.set("remember_me", "", { maxAge: 0, path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `app/api/auth/verify-code/route.ts`
|
||||||
|
**Modifications :**
|
||||||
|
```typescript
|
||||||
|
// Fonction helper pour éviter la duplication de code
|
||||||
|
function applyRememberMeCookies(response: NextResponse, rememberMe?: boolean) {
|
||||||
|
if (rememberMe) {
|
||||||
|
// Même logique que signin-password
|
||||||
|
response.cookies.set("remember_me", "true", { ... });
|
||||||
|
// Prolonger cookies Supabase
|
||||||
|
} else {
|
||||||
|
response.cookies.set("remember_me", "", { maxAge: 0, path: "/" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraction du paramètre
|
||||||
|
const { email, code, rememberMe } = await req.json();
|
||||||
|
|
||||||
|
// Application avant le retour
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
applyRememberMeCookies(response, rememberMe);
|
||||||
|
return response;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. `middleware.ts`
|
||||||
|
**Modifications :**
|
||||||
|
```typescript
|
||||||
|
// Après getSession(), maintenir les cookies persistants
|
||||||
|
const rememberMeCookie = req.cookies.get("remember_me");
|
||||||
|
if (session && rememberMeCookie?.value === "true") {
|
||||||
|
const cookieOptions = {
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renouveler tous les cookies Supabase
|
||||||
|
req.cookies.getAll().forEach((cookie) => {
|
||||||
|
if (cookie.name.startsWith("sb-")) {
|
||||||
|
res.cookies.set(cookie.name, cookie.value, cookieOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renouveler le cookie remember_me
|
||||||
|
res.cookies.set("remember_me", "true", cookieOptions);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Sécurité
|
||||||
|
|
||||||
|
### Mesures de protection
|
||||||
|
1. **Opt-in par défaut** : La checkbox n'est pas cochée automatiquement
|
||||||
|
2. **Avertissement visible** : Mini-card affichée dès que cochée
|
||||||
|
3. **Modal éducatif** : Explique les risques en détail
|
||||||
|
4. **Cookies httpOnly** : Protégés contre le vol via JavaScript (XSS)
|
||||||
|
5. **Cookies secure** : En production, transmis uniquement via HTTPS
|
||||||
|
6. **SameSite: lax** : Protection contre CSRF
|
||||||
|
|
||||||
|
### Bonnes pratiques respectées
|
||||||
|
- ✅ Consentement explicite de l'utilisateur
|
||||||
|
- ✅ Information claire sur les risques
|
||||||
|
- ✅ Durée limitée (30 jours, pas illimitée)
|
||||||
|
- ✅ Cookie traceur pour le middleware
|
||||||
|
- ✅ Renouvellement automatique des cookies à chaque requête
|
||||||
|
|
||||||
|
## 📊 Comportement
|
||||||
|
|
||||||
|
### Avec "Rester connecté" coché
|
||||||
|
1. Utilisateur se connecte avec la checkbox cochée
|
||||||
|
2. Cookies Supabase définis avec `maxAge: 30 jours`
|
||||||
|
3. Cookie `remember_me=true` créé
|
||||||
|
4. À chaque requête, le middleware renouvelle tous les cookies
|
||||||
|
5. L'utilisateur reste connecté 30 jours (ou jusqu'à déconnexion manuelle)
|
||||||
|
|
||||||
|
### Sans "Rester connecté" (défaut)
|
||||||
|
1. Utilisateur se connecte sans cocher
|
||||||
|
2. Cookies Supabase sans `maxAge` (cookies de session)
|
||||||
|
3. Pas de cookie `remember_me`
|
||||||
|
4. Les cookies expirent à la fermeture du navigateur
|
||||||
|
5. L'utilisateur doit se reconnecter à chaque visite
|
||||||
|
|
||||||
|
### Déconnexion manuelle
|
||||||
|
- La route `/api/auth/signout` supprime tous les cookies (y compris `remember_me`)
|
||||||
|
- Comportement identique que "Rester connecté" soit activé ou non
|
||||||
|
|
||||||
|
## 🎨 Design
|
||||||
|
|
||||||
|
### Style de la checkbox
|
||||||
|
- Accent color : `#6366f1` (indigo, cohérent avec le thème)
|
||||||
|
- Texte : Police medium, couleur `#171424`
|
||||||
|
- Taille : 18px × 18px
|
||||||
|
|
||||||
|
### Style de la mini-card
|
||||||
|
- Fond : Dégradé orange léger (`rgba(251, 191, 36, 0.12)` → `rgba(245, 158, 11, 0.08)`)
|
||||||
|
- Bordure : Orange transparent (`rgba(245, 158, 11, 0.3)`)
|
||||||
|
- Icône : Orange `#f59e0b`
|
||||||
|
- Animation : slideIn au montage (0.3s)
|
||||||
|
- Padding : 10px 12px
|
||||||
|
- Border-radius : 10px
|
||||||
|
|
||||||
|
### Style du modal
|
||||||
|
- Overlay : Noir 60% avec backdrop-filter blur
|
||||||
|
- Content : Blanc, border-radius 16px, shadow importante
|
||||||
|
- Sections colorées :
|
||||||
|
- Avantages : Vert (`#10b981`)
|
||||||
|
- Risques : Rouge (`#ef4444`)
|
||||||
|
- Recommandations : Orange (`#f59e0b`)
|
||||||
|
- Note sécurité : Bleu (`#3b82f6`)
|
||||||
|
- Animations : fadeIn + slideUp
|
||||||
|
|
||||||
|
## 🧪 Tests recommandés
|
||||||
|
|
||||||
|
### Tests fonctionnels
|
||||||
|
1. ✅ Cocher la checkbox → Mini-card s'affiche
|
||||||
|
2. ✅ Décocher la checkbox → Mini-card disparaît
|
||||||
|
3. ✅ Cliquer "Pourquoi ?" → Modal s'ouvre
|
||||||
|
4. ✅ Cliquer "J'ai compris" → Modal se ferme
|
||||||
|
5. ✅ Connexion avec checkbox cochée → Cookies 30 jours
|
||||||
|
6. ✅ Connexion sans checkbox → Cookies de session
|
||||||
|
7. ✅ Fermer navigateur avec "Rester connecté" → Toujours connecté
|
||||||
|
8. ✅ Fermer navigateur sans "Rester connecté" → Déconnecté
|
||||||
|
9. ✅ Déconnexion manuelle → Cookie `remember_me` supprimé
|
||||||
|
|
||||||
|
### Tests de sécurité
|
||||||
|
1. ✅ Cookies `httpOnly` → Non accessibles via `document.cookie`
|
||||||
|
2. ✅ Cookies `secure` en prod → HTTPS uniquement
|
||||||
|
3. ✅ Cookies `sameSite: lax` → Protection CSRF
|
||||||
|
4. ✅ Durée limitée → Expiration après 30 jours
|
||||||
|
|
||||||
|
### Tests UX
|
||||||
|
1. ✅ Modal responsive sur mobile
|
||||||
|
2. ✅ Animations fluides
|
||||||
|
3. ✅ Textes clairs et informatifs
|
||||||
|
4. ✅ Couleurs cohérentes avec le design system
|
||||||
|
|
||||||
|
## 📱 Responsive
|
||||||
|
|
||||||
|
- Desktop : Affichage optimal, modal centré
|
||||||
|
- Tablet : Idem desktop
|
||||||
|
- Mobile :
|
||||||
|
- Mini-card peut wrap sur 2 lignes
|
||||||
|
- Modal occupe 95vh max
|
||||||
|
- Padding réduit
|
||||||
|
- Tailles de police adaptées
|
||||||
|
|
||||||
|
## 🔄 Compatibilité
|
||||||
|
|
||||||
|
- ✅ Authentification par mot de passe
|
||||||
|
- ✅ Authentification par code email (OTP)
|
||||||
|
- ✅ Authentification avec 2FA (MFA)
|
||||||
|
- ✅ Mode maintenance staff
|
||||||
|
- ✅ Tous les navigateurs modernes
|
||||||
|
|
||||||
|
## 📝 Logs de debug
|
||||||
|
|
||||||
|
Les routes API loggent l'activation/désactivation :
|
||||||
|
```
|
||||||
|
✅ [signin-password] Cookies persistants activés pour 30 jours
|
||||||
|
ℹ️ [signin-password] Cookies de session (non persistants)
|
||||||
|
✅ [verify-code] Cookies persistants activés pour 30 jours
|
||||||
|
ℹ️ [verify-code] Cookies de session (non persistants)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Déploiement
|
||||||
|
|
||||||
|
### Variables d'environnement requises
|
||||||
|
Aucune nouvelle variable. Utilise :
|
||||||
|
- `NODE_ENV` (pour le flag `secure` des cookies)
|
||||||
|
- Variables Supabase existantes
|
||||||
|
|
||||||
|
### Checklist de déploiement
|
||||||
|
- ✅ Vérifier que `NODE_ENV=production` en prod
|
||||||
|
- ✅ Vérifier que le site est en HTTPS
|
||||||
|
- ✅ Tester la persistance des cookies après build
|
||||||
|
- ✅ Vérifier les logs de cookies dans les API routes
|
||||||
|
|
||||||
|
## 🔮 Améliorations futures possibles
|
||||||
|
|
||||||
|
1. **Durée configurable** : Permettre à l'utilisateur de choisir (7j, 15j, 30j, 90j)
|
||||||
|
2. **Dashboard de sessions** : Page montrant les sessions actives et permettant de les révoquer
|
||||||
|
3. **Notification email** : Alerter l'utilisateur quand une nouvelle session longue durée est créée
|
||||||
|
4. **Géolocalisation** : Afficher la localisation approximative de la connexion
|
||||||
|
5. **Historique de connexions** : Log des connexions avec IP, date, navigateur
|
||||||
|
6. **Révocation à distance** : Permettre de se déconnecter de toutes les sessions sauf la courante
|
||||||
|
|
||||||
|
## 📚 Références
|
||||||
|
|
||||||
|
- [Supabase Auth - Persistent Sessions](https://supabase.com/docs/guides/auth/sessions)
|
||||||
|
- [Next.js Cookies](https://nextjs.org/docs/app/api-reference/functions/cookies)
|
||||||
|
- [MDN - HTTP Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)
|
||||||
|
- [OWASP - Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||||||
|
|
||||||
|
## ✅ Statut
|
||||||
|
|
||||||
|
**Implémentation complète** : ✅ Terminée le 16 octobre 2025
|
||||||
|
|
||||||
|
Tous les fichiers ont été modifiés et testés. La fonctionnalité est prête pour la production.
|
||||||
284
REMEMBER_ME_IMPLEMENTATION_SUMMARY.md
Normal file
284
REMEMBER_ME_IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
# ✅ Implémentation terminée : Feature "Rester connecté"
|
||||||
|
|
||||||
|
**Date** : 16 octobre 2025
|
||||||
|
**Statut** : ✅ Complète et prête pour la production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Résultat final
|
||||||
|
|
||||||
|
Vous avez maintenant une fonctionnalité complète "Rester connecté pendant 30 jours" avec :
|
||||||
|
|
||||||
|
### ✨ Interface utilisateur
|
||||||
|
- ✅ Checkbox élégante sur la page de connexion
|
||||||
|
- ✅ Mini-card d'avertissement (affichée uniquement si cochée)
|
||||||
|
- ✅ Bouton "Pourquoi ?" pour ouvrir le modal explicatif
|
||||||
|
- ✅ Modal informatif avec avantages, risques et recommandations
|
||||||
|
|
||||||
|
### 🔧 Fonctionnalités techniques
|
||||||
|
- ✅ Cookies persistants de 30 jours (si checkbox cochée)
|
||||||
|
- ✅ Cookies de session (si checkbox non cochée, comportement par défaut)
|
||||||
|
- ✅ Renouvellement automatique des cookies à chaque requête
|
||||||
|
- ✅ Cookie `remember_me` pour tracer la préférence utilisateur
|
||||||
|
|
||||||
|
### 🔒 Sécurité
|
||||||
|
- ✅ Opt-in par défaut (non coché)
|
||||||
|
- ✅ Avertissement clair sur les risques
|
||||||
|
- ✅ Cookies httpOnly (protection XSS)
|
||||||
|
- ✅ Cookies secure en production (HTTPS)
|
||||||
|
- ✅ SameSite: lax (protection CSRF)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Fichiers créés
|
||||||
|
|
||||||
|
### Nouveaux composants
|
||||||
|
```
|
||||||
|
components/auth/
|
||||||
|
├── RememberMeInfoModal.tsx ← Modal explicatif React
|
||||||
|
└── RememberMeInfoModal.module.css ← Styles du modal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Fichiers modifiés
|
||||||
|
|
||||||
|
### 1. Interface de connexion
|
||||||
|
```
|
||||||
|
app/signin/page.tsx
|
||||||
|
app/signin/signin.module.css
|
||||||
|
```
|
||||||
|
**Changements :**
|
||||||
|
- Ajout de l'état `rememberMe` et `showRememberMeModal`
|
||||||
|
- Import du composant `RememberMeInfoModal`
|
||||||
|
- Ajout de la checkbox avec mini-card conditionnelle
|
||||||
|
- Envoi de `rememberMe` aux API dans les requêtes de connexion
|
||||||
|
|
||||||
|
### 2. APIs d'authentification
|
||||||
|
```
|
||||||
|
app/api/auth/signin-password/route.ts ← Connexion par mot de passe
|
||||||
|
app/api/auth/verify-code/route.ts ← Connexion par code email (OTP)
|
||||||
|
```
|
||||||
|
**Changements :**
|
||||||
|
- Extraction du paramètre `rememberMe` du body
|
||||||
|
- Application des cookies persistants si `rememberMe === true`
|
||||||
|
- Cookie `remember_me` créé pour le middleware
|
||||||
|
- Logs de debug ajoutés
|
||||||
|
|
||||||
|
### 3. Middleware
|
||||||
|
```
|
||||||
|
middleware.ts
|
||||||
|
```
|
||||||
|
**Changements :**
|
||||||
|
- Détection du cookie `remember_me`
|
||||||
|
- Renouvellement automatique des cookies Supabase si activé
|
||||||
|
- Maintien de la persistance à chaque requête
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Aperçu visuel
|
||||||
|
|
||||||
|
### Checkbox non cochée (défaut)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Email : [____________] │
|
||||||
|
│ Password : [____________] 👁️ │
|
||||||
|
│ │
|
||||||
|
│ ☐ Rester connecté pendant 30 jours│
|
||||||
|
│ │
|
||||||
|
│ [ Se connecter ] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkbox cochée (avec avertissement)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Email : [____________] │
|
||||||
|
│ Password : [____________] 👁️ │
|
||||||
|
│ │
|
||||||
|
│ ☑ Rester connecté pendant 30 jours│
|
||||||
|
│ ┌───────────────────────────────┐ │
|
||||||
|
│ │ ⚠️ Recommandé uniquement sur │ │
|
||||||
|
│ │ un ordinateur non partagé │ │
|
||||||
|
│ │ [Pourquoi ?] │ │
|
||||||
|
│ └───────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [ Se connecter ] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal "Pourquoi ?" (clic sur le bouton)
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ 🛡️ À propos de "Rester connecté" ✕│
|
||||||
|
├────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ✅ Avantages │
|
||||||
|
│ • Connexion automatique pendant 30j │
|
||||||
|
│ • Gain de temps │
|
||||||
|
│ • Sécurisé par cookies cryptés │
|
||||||
|
│ │
|
||||||
|
│ ❌ Risques sur ordinateur partagé │
|
||||||
|
│ • Accès non autorisé possible │
|
||||||
|
│ • Données sensibles accessibles │
|
||||||
|
│ • Session longue durée │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ Nos recommandations │
|
||||||
|
│ ✓ Cochez si : Ordinateur personnel │
|
||||||
|
│ ✗ Ne cochez pas : Ordinateur public │
|
||||||
|
│ │
|
||||||
|
│ 🛡️ Note de sécurité │
|
||||||
|
│ Déconnectez-vous manuellement sur │
|
||||||
|
│ ordinateur partagé. │
|
||||||
|
│ │
|
||||||
|
├────────────────────────────────────────┤
|
||||||
|
│ [J'ai compris] │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flux de fonctionnement
|
||||||
|
|
||||||
|
### Avec "Rester connecté" coché ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Utilisateur coche la checkbox
|
||||||
|
↓
|
||||||
|
2. Mini-card d'avertissement s'affiche
|
||||||
|
↓
|
||||||
|
3. Utilisateur se connecte
|
||||||
|
↓
|
||||||
|
4. API crée cookies Supabase avec maxAge: 30 jours
|
||||||
|
↓
|
||||||
|
5. Cookie remember_me=true créé
|
||||||
|
↓
|
||||||
|
6. Middleware renouvelle les cookies à chaque requête
|
||||||
|
↓
|
||||||
|
7. Utilisateur reste connecté 30 jours
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sans "Rester connecté" (défaut) ⬜
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Utilisateur laisse décochée
|
||||||
|
↓
|
||||||
|
2. Pas de mini-card
|
||||||
|
↓
|
||||||
|
3. Utilisateur se connecte
|
||||||
|
↓
|
||||||
|
4. API crée cookies Supabase SANS maxAge
|
||||||
|
↓
|
||||||
|
5. Pas de cookie remember_me
|
||||||
|
↓
|
||||||
|
6. Cookies expirent à la fermeture du navigateur
|
||||||
|
↓
|
||||||
|
7. Utilisateur doit se reconnecter à chaque visite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Comment tester
|
||||||
|
|
||||||
|
### Test 1 : Checkbox non cochée
|
||||||
|
1. Allez sur `/signin`
|
||||||
|
2. Ne cochez PAS la checkbox
|
||||||
|
3. Connectez-vous
|
||||||
|
4. Fermez complètement le navigateur
|
||||||
|
5. Rouvrez et retournez sur le site
|
||||||
|
6. **Résultat attendu** : Vous devez vous reconnecter ✅
|
||||||
|
|
||||||
|
### Test 2 : Checkbox cochée
|
||||||
|
1. Allez sur `/signin`
|
||||||
|
2. **Cochez** la checkbox "Rester connecté"
|
||||||
|
3. La mini-card orange doit apparaître ✅
|
||||||
|
4. Connectez-vous
|
||||||
|
5. Fermez complètement le navigateur
|
||||||
|
6. Rouvrez et retournez sur le site
|
||||||
|
7. **Résultat attendu** : Vous êtes toujours connecté ✅
|
||||||
|
|
||||||
|
### Test 3 : Modal "Pourquoi ?"
|
||||||
|
1. Allez sur `/signin`
|
||||||
|
2. Cochez la checkbox
|
||||||
|
3. Cliquez sur "Pourquoi ?"
|
||||||
|
4. **Résultat attendu** : Modal s'ouvre avec les explications ✅
|
||||||
|
5. Cliquez sur "J'ai compris"
|
||||||
|
6. **Résultat attendu** : Modal se ferme ✅
|
||||||
|
|
||||||
|
### Test 4 : Sécurité des cookies
|
||||||
|
1. Connectez-vous avec "Rester connecté"
|
||||||
|
2. Ouvrez la console développeur
|
||||||
|
3. Tapez : `document.cookie`
|
||||||
|
4. **Résultat attendu** : Les cookies Supabase ne sont PAS visibles (httpOnly) ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Cookies créés
|
||||||
|
|
||||||
|
### Si "Rester connecté" coché
|
||||||
|
```
|
||||||
|
remember_me=true; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
sb-[project]-auth-token=...; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
sb-[project]-auth-token.0=...; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
sb-[project]-auth-token.1=...; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
```
|
||||||
|
|
||||||
|
### Si "Rester connecté" non coché (défaut)
|
||||||
|
```
|
||||||
|
sb-[project]-auth-token=...; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
sb-[project]-auth-token.0=...; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
sb-[project]-auth-token.1=...; Path=/; HttpOnly; Secure; SameSite=Lax
|
||||||
|
(Pas de Max-Age → cookies de session)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Prêt pour la production
|
||||||
|
|
||||||
|
La fonctionnalité est **complète et testée** :
|
||||||
|
- ✅ Aucune erreur TypeScript
|
||||||
|
- ✅ Styles responsive (mobile, tablet, desktop)
|
||||||
|
- ✅ Animations fluides
|
||||||
|
- ✅ Messages clairs et informatifs
|
||||||
|
- ✅ Sécurité renforcée (httpOnly, secure, sameSite)
|
||||||
|
- ✅ Logs de debug
|
||||||
|
- ✅ Documentation complète
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Consultez `REMEMBER_ME_FEATURE.md` pour :
|
||||||
|
- Architecture technique détaillée
|
||||||
|
- Code snippets complets
|
||||||
|
- Bonnes pratiques de sécurité
|
||||||
|
- Améliorations futures possibles
|
||||||
|
- Références et ressources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Utilisation pour les utilisateurs
|
||||||
|
|
||||||
|
### Quand cocher "Rester connecté" ?
|
||||||
|
✅ **Oui, cochez** si :
|
||||||
|
- Vous êtes sur votre ordinateur personnel
|
||||||
|
- Vous êtes sur votre ordinateur professionnel (non partagé)
|
||||||
|
- Vous êtes le seul à utiliser cet appareil
|
||||||
|
|
||||||
|
❌ **Non, ne cochez pas** si :
|
||||||
|
- Vous êtes sur un ordinateur public (cybercafé, bibliothèque)
|
||||||
|
- Vous partagez cet ordinateur avec d'autres personnes
|
||||||
|
- Vous êtes sur l'ordinateur d'un ami
|
||||||
|
- Vous utilisez un ordinateur de travail partagé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Résultat
|
||||||
|
|
||||||
|
Vous avez maintenant un système de connexion moderne avec :
|
||||||
|
1. **Flexibilité** : L'utilisateur choisit
|
||||||
|
2. **Sécurité** : Avertissements clairs
|
||||||
|
3. **Transparence** : Modal explicatif
|
||||||
|
4. **UX optimale** : Connexion automatique si souhaité
|
||||||
|
|
||||||
|
**Félicitations !** La feature est prête à être déployée. 🚀
|
||||||
397
SIGNATURE_SALARIE_PRENOM_FEATURE.md
Normal file
397
SIGNATURE_SALARIE_PRENOM_FEATURE.md
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
# 📧 Personnalisation Email Signature Salarié avec Prénom
|
||||||
|
|
||||||
|
**Date**: 16 octobre 2025
|
||||||
|
|
||||||
|
## 🎯 Objectif
|
||||||
|
|
||||||
|
Personnaliser l'email de signature électronique envoyé aux salariés en utilisant leur prénom dans la salutation "Bonjour [Prénom]". Le prénom est récupéré depuis la colonne `prenom` de la table `salaries` dans Supabase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Fonctionnalité
|
||||||
|
|
||||||
|
### Comportement
|
||||||
|
|
||||||
|
Lorsqu'un email de signature électronique est envoyé à un salarié :
|
||||||
|
|
||||||
|
1. **Si la Lambda fournit le prénom** → Utilisé directement
|
||||||
|
2. **Si le prénom n'est pas fourni** → Recherche dans la table `salaries` de Supabase
|
||||||
|
- Recherche par `matricule` (priorité) ou `adresse_mail`
|
||||||
|
- Filtre par `employer_id` si disponible
|
||||||
|
3. **Si aucun prénom trouvé** → Fallback sur "Salarié"
|
||||||
|
|
||||||
|
### Email généré
|
||||||
|
|
||||||
|
```
|
||||||
|
Bonjour Jean,
|
||||||
|
|
||||||
|
Nous vous invitons à signer votre contrat de travail ci-dessous...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Implémentation
|
||||||
|
|
||||||
|
### Fichier modifié
|
||||||
|
|
||||||
|
**`/app/api/emails/signature-salarie/route.ts`**
|
||||||
|
|
||||||
|
#### Étape 1 : Renommer le paramètre firstName
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
employeeEmail,
|
||||||
|
signatureLink,
|
||||||
|
reference,
|
||||||
|
firstName: providedFirstName, // ← Renommé pour clarté
|
||||||
|
organizationName,
|
||||||
|
matricule,
|
||||||
|
// ...
|
||||||
|
} = data;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Étape 2 : Récupération depuis Supabase
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 3. Récupération du prénom depuis Supabase si non fourni
|
||||||
|
let firstName = providedFirstName;
|
||||||
|
|
||||||
|
if (!firstName && (matricule || employeeEmail)) {
|
||||||
|
console.log('🔍 Récupération du prénom depuis la table salaries...');
|
||||||
|
try {
|
||||||
|
const supabase = createSbServiceRole();
|
||||||
|
|
||||||
|
// Recherche par matricule ou email
|
||||||
|
let query = supabase
|
||||||
|
.from('salaries')
|
||||||
|
.select('prenom')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
query = query.eq('employer_id', organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priorité au matricule
|
||||||
|
if (matricule) {
|
||||||
|
query = query.or(`code_salarie.eq.${matricule},num_salarie.eq.${matricule}`);
|
||||||
|
} else if (employeeEmail) {
|
||||||
|
query = query.eq('adresse_mail', employeeEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: salaryData, error: salaryError } = await query;
|
||||||
|
|
||||||
|
if (!salaryError && salaryData && salaryData[0]?.prenom) {
|
||||||
|
firstName = salaryData[0].prenom;
|
||||||
|
console.log('✅ Prénom trouvé dans Supabase:', firstName);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Prénom non trouvé dans Supabase');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('⚠️ Erreur lors de la récupération du prénom:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Étape 3 : Utilisation du prénom
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 4. Préparation des données de l'email
|
||||||
|
const emailData: EmailDataV2 = {
|
||||||
|
firstName: firstName || 'Salarié', // ← Fallback si toujours vide
|
||||||
|
organizationName: organizationName,
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Étape 4 : Logging amélioré
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log('📧 Préparation de l\'envoi de l\'email:', {
|
||||||
|
to: employeeEmail,
|
||||||
|
type: 'signature-request-salarie',
|
||||||
|
subject: `Signez votre contrat ${organizationName}`,
|
||||||
|
firstName: emailData.firstName // ← Affiche le prénom utilisé
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flux de données
|
||||||
|
|
||||||
|
### Cas 1 : Lambda fournit le prénom (DynamoDB)
|
||||||
|
|
||||||
|
```
|
||||||
|
Lambda AWS (DynamoDB)
|
||||||
|
↓ firstName: "Jean"
|
||||||
|
API /api/emails/signature-salarie
|
||||||
|
↓ Utilise "Jean"
|
||||||
|
Email envoyé avec "Bonjour Jean,"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cas 2 : Lambda ne fournit pas le prénom
|
||||||
|
|
||||||
|
```
|
||||||
|
Lambda AWS (DynamoDB)
|
||||||
|
↓ firstName: null/undefined
|
||||||
|
API /api/emails/signature-salarie
|
||||||
|
↓ Recherche dans Supabase
|
||||||
|
Supabase table salaries
|
||||||
|
↓ prenom: "Marie"
|
||||||
|
API utilise "Marie"
|
||||||
|
↓
|
||||||
|
Email envoyé avec "Bonjour Marie,"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cas 3 : Aucun prénom disponible
|
||||||
|
|
||||||
|
```
|
||||||
|
Lambda AWS (DynamoDB)
|
||||||
|
↓ firstName: null
|
||||||
|
API /api/emails/signature-salarie
|
||||||
|
↓ Recherche dans Supabase
|
||||||
|
Supabase table salaries
|
||||||
|
↓ Aucun résultat
|
||||||
|
API utilise fallback "Salarié"
|
||||||
|
↓
|
||||||
|
Email envoyé avec "Bonjour Salarié,"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Table Supabase utilisée
|
||||||
|
|
||||||
|
**Table**: `salaries`
|
||||||
|
|
||||||
|
### Colonnes consultées
|
||||||
|
|
||||||
|
| Colonne | Type | Description | Utilisation |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `prenom` | `text` | Prénom du salarié | ✅ Récupéré pour personnalisation |
|
||||||
|
| `code_salarie` | `text` | Code matricule | 🔍 Critère de recherche |
|
||||||
|
| `num_salarie` | `text` | Numéro salarié | 🔍 Critère de recherche (alternative) |
|
||||||
|
| `adresse_mail` | `text` | Email du salarié | 🔍 Critère de recherche (fallback) |
|
||||||
|
| `employer_id` | `uuid` | ID de l'employeur | 🔍 Filtre (si disponible) |
|
||||||
|
|
||||||
|
### Requête SQL équivalente
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT prenom
|
||||||
|
FROM salaries
|
||||||
|
WHERE employer_id = 'uuid-org-id'
|
||||||
|
AND (code_salarie = 'MAT123' OR num_salarie = 'MAT123')
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
### Test 1 : Prénom fourni par Lambda
|
||||||
|
|
||||||
|
**Payload envoyé à l'API :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employeeEmail": "jean.dupont@example.com",
|
||||||
|
"signatureLink": "https://...",
|
||||||
|
"reference": "CDDU-2025-001",
|
||||||
|
"firstName": "Jean",
|
||||||
|
"organizationName": "Théâtre National",
|
||||||
|
"matricule": "SAL001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultat attendu :**
|
||||||
|
- ✅ Email envoyé avec "Bonjour Jean,"
|
||||||
|
- 📝 Log : `firstName: emailData.firstName` → "Jean"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 2 : Prénom non fourni, recherche réussie
|
||||||
|
|
||||||
|
**Payload envoyé à l'API :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employeeEmail": "marie.martin@example.com",
|
||||||
|
"signatureLink": "https://...",
|
||||||
|
"reference": "CDDU-2025-002",
|
||||||
|
"firstName": null,
|
||||||
|
"organizationName": "Opéra de Paris",
|
||||||
|
"matricule": "SAL002",
|
||||||
|
"organizationId": "uuid-org"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logs attendus :**
|
||||||
|
```
|
||||||
|
🔍 Récupération du prénom depuis la table salaries...
|
||||||
|
✅ Prénom trouvé dans Supabase: Marie
|
||||||
|
📧 Préparation de l'envoi de l'email: { firstName: "Marie", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultat attendu :**
|
||||||
|
- ✅ Email envoyé avec "Bonjour Marie,"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 3 : Aucun prénom disponible
|
||||||
|
|
||||||
|
**Payload envoyé à l'API :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employeeEmail": "nouveau@example.com",
|
||||||
|
"signatureLink": "https://...",
|
||||||
|
"reference": "CDDU-2025-003",
|
||||||
|
"firstName": null,
|
||||||
|
"organizationName": "Comédie Française",
|
||||||
|
"matricule": "UNKNOWN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logs attendus :**
|
||||||
|
```
|
||||||
|
🔍 Récupération du prénom depuis la table salaries...
|
||||||
|
⚠️ Prénom non trouvé dans Supabase
|
||||||
|
📧 Préparation de l'envoi de l'email: { firstName: "Salarié", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultat attendu :**
|
||||||
|
- ✅ Email envoyé avec "Bonjour Salarié,"
|
||||||
|
- ⚠️ Message générique mais email toujours envoyé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Template Email
|
||||||
|
|
||||||
|
Le template utilise Handlebars pour la personnalisation :
|
||||||
|
|
||||||
|
**`lib/emailTemplateService.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'signature-request-salarie': {
|
||||||
|
subject: 'Signez votre contrat {{organizationName}}',
|
||||||
|
title: 'Demande de signature électronique',
|
||||||
|
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}', // ← Utilise firstName
|
||||||
|
mainMessage: 'Nous vous invitons à signer votre contrat de travail ci-dessous...',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Sécurité
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
L'API `/api/emails/signature-salarie` est protégée par API Key :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const apiKey = request.headers.get('X-API-Key');
|
||||||
|
const validApiKey = ENV.LAMBDA_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey || apiKey !== validApiKey) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions Supabase
|
||||||
|
|
||||||
|
Utilise `createSbServiceRole()` pour accéder à la table `salaries` avec des permissions élevées :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const supabase = createSbServiceRole();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checklist de déploiement
|
||||||
|
|
||||||
|
- [x] Modification de `/app/api/emails/signature-salarie/route.ts`
|
||||||
|
- [x] Ajout de la récupération du prénom depuis Supabase
|
||||||
|
- [x] Gestion du fallback "Salarié"
|
||||||
|
- [x] Logging amélioré avec `firstName` affiché
|
||||||
|
- [x] Aucune modification nécessaire dans la Lambda (rétrocompatible)
|
||||||
|
- [x] Aucune modification nécessaire dans le template email (déjà configuré)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Avantages
|
||||||
|
|
||||||
|
1. **Rétrocompatible** : Si la Lambda envoie déjà le prénom, il est utilisé directement
|
||||||
|
2. **Fallback automatique** : Recherche dans Supabase si le prénom n'est pas fourni
|
||||||
|
3. **Robuste** : Gestion des erreurs sans bloquer l'envoi d'email
|
||||||
|
4. **Logging détaillé** : Suivi clair du prénom utilisé dans les logs
|
||||||
|
5. **Personnalisation améliorée** : Meilleure expérience utilisateur pour les salariés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Dépannage
|
||||||
|
|
||||||
|
### Le prénom n'apparaît pas dans l'email
|
||||||
|
|
||||||
|
**Vérifier :**
|
||||||
|
|
||||||
|
1. **Les logs de l'API** :
|
||||||
|
```
|
||||||
|
📦 Données reçues: { firstName: null, matricule: "SAL001", ... }
|
||||||
|
🔍 Récupération du prénom depuis la table salaries...
|
||||||
|
✅ Prénom trouvé dans Supabase: Jean
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **La table `salaries`** dans Supabase :
|
||||||
|
- Le salarié existe avec ce matricule ?
|
||||||
|
- La colonne `prenom` est bien remplie ?
|
||||||
|
- L'`employer_id` correspond ?
|
||||||
|
|
||||||
|
3. **Le template email** :
|
||||||
|
```typescript
|
||||||
|
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erreur de requête Supabase
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ Erreur lors de la récupération du prénom: [Error details]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Actions :**
|
||||||
|
- Vérifier que `SUPABASE_SERVICE_ROLE_KEY` est bien configuré
|
||||||
|
- Vérifier les permissions RLS sur la table `salaries`
|
||||||
|
- Vérifier que `createSbServiceRole()` utilise bien le service role
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Fichiers liés
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---------|------|
|
||||||
|
| `/app/api/emails/signature-salarie/route.ts` | API recevant la demande d'envoi email |
|
||||||
|
| `/lib/emailTemplateService.ts` | Configuration du template avec `greeting` |
|
||||||
|
| `/templates-mails/signature-electronique-salarie.html` | Template HTML (utilise Handlebars) |
|
||||||
|
| `/LAMBDA_SIGNATURE_SALARIE_UPDATED.js` | Code de la Lambda AWS |
|
||||||
|
| Table `salaries` (Supabase) | Source de données pour le prénom |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Résultat
|
||||||
|
|
||||||
|
Les emails de signature électronique sont maintenant personnalisés avec le prénom du salarié :
|
||||||
|
|
||||||
|
```
|
||||||
|
Objet : Signez votre contrat Théâtre National
|
||||||
|
|
||||||
|
Bonjour Jean,
|
||||||
|
|
||||||
|
Nous vous invitons à signer votre contrat de travail ci-dessous.
|
||||||
|
|
||||||
|
[Signer le contrat]
|
||||||
|
```
|
||||||
|
|
||||||
|
Au lieu de :
|
||||||
|
|
||||||
|
```
|
||||||
|
Objet : Signez votre contrat Théâtre National
|
||||||
|
|
||||||
|
Un document nécessite votre signature électronique.
|
||||||
|
|
||||||
|
[Signer le contrat]
|
||||||
|
```
|
||||||
|
|
@ -111,6 +111,11 @@ export default function NouveauSalariePage() {
|
||||||
// Onglets formulaire: simplifié / complet (design similaire à /contrats)
|
// Onglets formulaire: simplifié / complet (design similaire à /contrats)
|
||||||
const [formMode, setFormMode] = useState<"simplifie" | "complet">("simplifie");
|
const [formMode, setFormMode] = useState<"simplifie" | "complet">("simplifie");
|
||||||
|
|
||||||
|
// États pour la gestion des organisations (staff uniquement)
|
||||||
|
const [isStaff, setIsStaff] = useState(false);
|
||||||
|
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [civilite, setCivilite] = useState<"Monsieur" | "Madame" | "">("");
|
const [civilite, setCivilite] = useState<"Monsieur" | "Madame" | "">("");
|
||||||
const [nom, setNom] = useState("");
|
const [nom, setNom] = useState("");
|
||||||
|
|
@ -155,12 +160,50 @@ const [addrMeta, setAddrMeta] = useState<{
|
||||||
}
|
}
|
||||||
}, [prenom]);
|
}, [prenom]);
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur est staff et récupérer les organisations
|
||||||
|
useEffect(() => {
|
||||||
|
const checkStaffAndLoadOrgs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/me", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const me = await res.json();
|
||||||
|
const userIsStaff = me.is_staff || false;
|
||||||
|
setIsStaff(userIsStaff);
|
||||||
|
|
||||||
|
// Si l'utilisateur est staff, récupérer la liste des organisations
|
||||||
|
if (userIsStaff) {
|
||||||
|
const orgRes = await fetch("/api/organizations", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orgRes.ok) {
|
||||||
|
const orgData = await orgRes.json();
|
||||||
|
setOrganizations(orgData.items || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la vérification staff:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkStaffAndLoadOrgs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Validation simple
|
// Validation simple
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
const coreOk = civilite !== "" && nom.trim() && prenom.trim() && emailRx.test(email.trim());
|
const coreOk = civilite !== "" && nom.trim() && prenom.trim() && emailRx.test(email.trim());
|
||||||
const ibanOk = !iban || isValidIBAN(iban);
|
const ibanOk = !iban || isValidIBAN(iban);
|
||||||
return coreOk && ibanOk;
|
// Si staff, vérifier qu'une organisation est sélectionnée
|
||||||
}, [civilite, nom, prenom, email, iban]);
|
const orgOk = !isStaff || (isStaff && selectedOrg !== null);
|
||||||
|
return coreOk && ibanOk && orgOk;
|
||||||
|
}, [civilite, nom, prenom, email, iban, isStaff, selectedOrg]);
|
||||||
|
|
||||||
// considère le formulaire comme "modifié" si l'un des champs est rempli/modifié
|
// considère le formulaire comme "modifié" si l'un des champs est rempli/modifié
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
|
|
@ -264,7 +307,9 @@ if (addrMeta?.lon) fd.append("adresse_lon", addrMeta.lon);
|
||||||
iban_salarie: iban.trim() || undefined,
|
iban_salarie: iban.trim() || undefined,
|
||||||
bic_salarie: bic.trim() || undefined,
|
bic_salarie: bic.trim() || undefined,
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
structure: clientInfo?.name || undefined,
|
structure: selectedOrg?.name || clientInfo?.name || undefined,
|
||||||
|
// Ajouter l'organisation sélectionnée si staff
|
||||||
|
employer_id: isStaff && selectedOrg ? selectedOrg.id : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch('/api/salaries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
const res = await fetch('/api/salaries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
|
|
@ -417,6 +462,37 @@ useEffect(() => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sélection d'organisation (pour les utilisateurs staff uniquement) */}
|
||||||
|
{isStaff && (
|
||||||
|
<Section title="Organisation">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[240px_1fr] items-center gap-4">
|
||||||
|
<Label required>Structure</Label>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedOrg?.id || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const orgId = e.target.value;
|
||||||
|
const org = organizations.find(o => o.id === orgId);
|
||||||
|
setSelectedOrg(org || null);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner une structure...</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>
|
||||||
|
{org.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">
|
||||||
|
Sélectionnez l'organisation pour laquelle vous créez ce salarié.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-5">
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
{/* Formulaire simplifié: civilité, nom d’usage, prénom, e‑mail */}
|
{/* Formulaire simplifié: civilité, nom d’usage, prénom, e‑mail */}
|
||||||
{formMode === "simplifie" && (
|
{formMode === "simplifie" && (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { email, password, mfaCode } = await req.json();
|
const { email, password, mfaCode, rememberMe } = await req.json();
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return NextResponse.json({ error: "Email et mot de passe requis" }, { status: 400 });
|
return NextResponse.json({ error: "Email et mot de passe requis" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +119,46 @@ export async function POST(req: Request) {
|
||||||
return NextResponse.json({ error: "Compte révoqué" }, { status: 403 });
|
return NextResponse.json({ error: "Compte révoqué" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
// 3) Gérer les cookies persistants si "rememberMe" est activé
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
if (rememberMe) {
|
||||||
|
// Définir un cookie remember_me pour le middleware
|
||||||
|
response.cookies.set("remember_me", "true", {
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 jours
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prolonger la durée des cookies Supabase
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const allCookies = cookieStore.getAll();
|
||||||
|
|
||||||
|
allCookies.forEach((cookie) => {
|
||||||
|
if (cookie.name.startsWith("sb-")) {
|
||||||
|
response.cookies.set(cookie.name, cookie.value, {
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 jours
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [signin-password] Cookies persistants activés pour 30 jours");
|
||||||
|
} else {
|
||||||
|
// Supprimer le cookie remember_me s'il existe
|
||||||
|
response.cookies.set("remember_me", "", {
|
||||||
|
maxAge: 0,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
console.log("ℹ️ [signin-password] Cookies de session (non persistants)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Erreur signin-password:", e);
|
console.error("Erreur signin-password:", e);
|
||||||
return NextResponse.json({ error: e?.message || "Erreur interne" }, { status: 500 });
|
return NextResponse.json({ error: e?.message || "Erreur interne" }, { status: 500 });
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,50 @@ import { cookies } from "next/headers";
|
||||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique les cookies persistants si rememberMe est activé
|
||||||
|
*/
|
||||||
|
function applyRememberMeCookies(response: NextResponse, rememberMe?: boolean) {
|
||||||
|
if (rememberMe) {
|
||||||
|
// Définir un cookie remember_me pour le middleware
|
||||||
|
response.cookies.set("remember_me", "true", {
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 jours
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prolonger la durée des cookies Supabase
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const allCookies = cookieStore.getAll();
|
||||||
|
|
||||||
|
allCookies.forEach((cookie) => {
|
||||||
|
if (cookie.name.startsWith("sb-")) {
|
||||||
|
response.cookies.set(cookie.name, cookie.value, {
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 jours
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [verify-code] Cookies persistants activés pour 30 jours");
|
||||||
|
} else {
|
||||||
|
// Supprimer le cookie remember_me s'il existe
|
||||||
|
response.cookies.set("remember_me", "", {
|
||||||
|
maxAge: 0,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
console.log("ℹ️ [verify-code] Cookies de session (non persistants)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { email, code } = await req.json();
|
const { email, code, rememberMe } = await req.json();
|
||||||
if (!email || !code) return new NextResponse("Email et code requis", { status: 400 });
|
if (!email || !code) return new NextResponse("Email et code requis", { status: 400 });
|
||||||
|
|
||||||
const supabase = createRouteHandlerClient({ cookies });
|
const supabase = createRouteHandlerClient({ cookies });
|
||||||
|
|
@ -29,7 +70,9 @@ export async function POST(req: Request) {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (su?.is_staff) {
|
if (su?.is_staff) {
|
||||||
return NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
|
applyRememberMeCookies(response, rememberMe);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Sinon, vérifier la révocation (user à une seule org)
|
// 2) Sinon, vérifier la révocation (user à une seule org)
|
||||||
|
|
@ -44,7 +87,9 @@ export async function POST(req: Request) {
|
||||||
return new NextResponse("revoked", { status: 403 });
|
return new NextResponse("revoked", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
|
applyRememberMeCookies(response, rememberMe);
|
||||||
|
return response;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return new NextResponse(e?.message || "Internal Server Error", { status: 500 });
|
return new NextResponse(e?.message || "Internal Server Error", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export async function POST(request: NextRequest) {
|
||||||
employeeEmail,
|
employeeEmail,
|
||||||
signatureLink,
|
signatureLink,
|
||||||
reference,
|
reference,
|
||||||
firstName,
|
firstName: providedFirstName,
|
||||||
organizationName,
|
organizationName,
|
||||||
matricule,
|
matricule,
|
||||||
typecontrat,
|
typecontrat,
|
||||||
|
|
@ -74,7 +74,45 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Préparation des données de l'email
|
// 3. Récupération du prénom depuis Supabase si non fourni
|
||||||
|
let firstName = providedFirstName;
|
||||||
|
|
||||||
|
if (!firstName && (matricule || employeeEmail)) {
|
||||||
|
console.log('🔍 Récupération du prénom depuis la table salaries...');
|
||||||
|
try {
|
||||||
|
const supabase = createSbServiceRole();
|
||||||
|
|
||||||
|
// Recherche par matricule ou email
|
||||||
|
let query = supabase
|
||||||
|
.from('salaries')
|
||||||
|
.select('prenom')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
query = query.eq('employer_id', organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priorité au matricule
|
||||||
|
if (matricule) {
|
||||||
|
query = query.or(`code_salarie.eq.${matricule},num_salarie.eq.${matricule}`);
|
||||||
|
} else if (employeeEmail) {
|
||||||
|
query = query.eq('adresse_mail', employeeEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: salaryData, error: salaryError } = await query;
|
||||||
|
|
||||||
|
if (!salaryError && salaryData && salaryData[0]?.prenom) {
|
||||||
|
firstName = salaryData[0].prenom;
|
||||||
|
console.log('✅ Prénom trouvé dans Supabase:', firstName);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Prénom non trouvé dans Supabase');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('⚠️ Erreur lors de la récupération du prénom:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Préparation des données de l'email
|
||||||
const emailData: EmailDataV2 = {
|
const emailData: EmailDataV2 = {
|
||||||
firstName: firstName || 'Salarié',
|
firstName: firstName || 'Salarié',
|
||||||
organizationName: organizationName,
|
organizationName: organizationName,
|
||||||
|
|
@ -93,10 +131,11 @@ export async function POST(request: NextRequest) {
|
||||||
console.log('📧 Préparation de l\'envoi de l\'email:', {
|
console.log('📧 Préparation de l\'envoi de l\'email:', {
|
||||||
to: employeeEmail,
|
to: employeeEmail,
|
||||||
type: 'signature-request-salarie',
|
type: 'signature-request-salarie',
|
||||||
subject: `Signez votre contrat ${organizationName}`
|
subject: `Signez votre contrat ${organizationName}`,
|
||||||
|
firstName: emailData.firstName
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Envoi de l'email via le système universel v2
|
// 5. Envoi de l'email via le système universel v2
|
||||||
const messageId = await sendUniversalEmailV2({
|
const messageId = await sendUniversalEmailV2({
|
||||||
type: 'signature-request-salarie',
|
type: 'signature-request-salarie',
|
||||||
toEmail: employeeEmail,
|
toEmail: employeeEmail,
|
||||||
|
|
@ -109,7 +148,7 @@ export async function POST(request: NextRequest) {
|
||||||
reference
|
reference
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Stocker le signature_link dans cddu_contracts pour permettre la vérification
|
// 6. Stocker le signature_link dans cddu_contracts pour permettre la vérification
|
||||||
if (contractId && signatureLink) {
|
if (contractId && signatureLink) {
|
||||||
console.log('💾 Stockage du signature_link dans cddu_contracts...');
|
console.log('💾 Stockage du signature_link dans cddu_contracts...');
|
||||||
|
|
||||||
|
|
@ -132,6 +171,8 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Retour du succès avec le messageId SES
|
||||||
|
|
||||||
// 6. Retour du succès avec le messageId SES
|
// 6. Retour du succès avec le messageId SES
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
// Récupérer les infos de l'organisation depuis les deux tables
|
// Récupérer les infos de l'organisation depuis les deux tables
|
||||||
const orgData = orgId ? await supabase.from('organizations').select('name').eq('id', orgId).single() : { data: null };
|
const orgData = orgId ? await supabase.from('organizations').select('name').eq('id', orgId).single() : { data: null };
|
||||||
const orgDetailsResult = orgId ? await supabase.from('organization_details').select('code_employeur, prenom_contact').eq('org_id', orgId).single() : { data: null, error: null };
|
const orgDetailsResult = orgId ? await supabase.from('organization_details').select('code_employeur, prenom_contact, email_notifs, email_notifs_cc').eq('org_id', orgId).single() : { data: null, error: null };
|
||||||
|
|
||||||
console.log('🔍 [EMAIL] Données récupérées:', {
|
console.log('🔍 [EMAIL] Données récupérées:', {
|
||||||
orgId,
|
orgId,
|
||||||
|
|
@ -429,29 +429,49 @@ export async function POST(req: NextRequest) {
|
||||||
data: orgData?.data ? {
|
data: orgData?.data ? {
|
||||||
name: orgData.data.name,
|
name: orgData.data.name,
|
||||||
code_employeur: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.code_employeur || null : null,
|
code_employeur: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.code_employeur || null : null,
|
||||||
prenom_contact: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.prenom_contact || null : null
|
prenom_contact: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.prenom_contact || null : null,
|
||||||
|
email_notifs: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.email_notifs || null : null,
|
||||||
|
email_notifs_cc: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.email_notifs_cc || null : null
|
||||||
} : null
|
} : null
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('🔍 [EMAIL] orgDetails final:', orgDetails);
|
console.log('🔍 [EMAIL] orgDetails final:', orgDetails);
|
||||||
|
|
||||||
// 1. Email de notification à l'équipe (existant)
|
// 1. Email de notification à l'employeur (corrigé: utilise email_notifs au lieu de user.email)
|
||||||
if (user && orgDetails?.data) {
|
if (orgDetails?.data?.email_notifs) {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
await sendUniversalEmailV2({
|
|
||||||
type: 'employee-created',
|
// Nettoyer et valider l'email principal
|
||||||
toEmail: user.email || 'paie@odentas.fr', // Fallback
|
const cleanEmail = (email: string | null): string | null => {
|
||||||
data: {
|
if (!email) return null;
|
||||||
userName: orgDetails.data.prenom_contact || user.user_metadata?.first_name || user.user_metadata?.display_name?.split(' ')[0] || 'Cher client',
|
return email.trim().toLowerCase();
|
||||||
companyName: orgDetails.data.name,
|
};
|
||||||
employerCode: orgDetails.data.code_employeur || 'N/A',
|
|
||||||
handlerName: 'Renaud BREVIERE-ABRAHAM',
|
const emailNotifs = cleanEmail(orgDetails.data.email_notifs);
|
||||||
employeeName: data.salarie,
|
const emailNotifsCC = cleanEmail(orgDetails.data.email_notifs_cc);
|
||||||
email: data.adresse_mail,
|
|
||||||
matricule: data.code_salarie,
|
console.log('📧 [EMAIL] Envoi à email_notifs:', emailNotifs);
|
||||||
ctaUrl: `${baseUrl}/salaries/${data.id}`,
|
console.log('📧 [EMAIL] CC à email_notifs_cc:', emailNotifsCC || 'Aucun');
|
||||||
}
|
|
||||||
});
|
if (emailNotifs) {
|
||||||
|
await sendUniversalEmailV2({
|
||||||
|
type: 'employee-created',
|
||||||
|
toEmail: emailNotifs,
|
||||||
|
ccEmail: emailNotifsCC || undefined,
|
||||||
|
data: {
|
||||||
|
userName: orgDetails.data.prenom_contact || user?.user_metadata?.first_name || user?.user_metadata?.display_name?.split(' ')[0] || 'Cher client',
|
||||||
|
companyName: orgDetails.data.name,
|
||||||
|
employerCode: orgDetails.data.code_employeur || 'N/A',
|
||||||
|
handlerName: 'Renaud BREVIERE-ABRAHAM',
|
||||||
|
employeeName: data.salarie,
|
||||||
|
email: data.adresse_mail,
|
||||||
|
matricule: data.code_salarie,
|
||||||
|
ctaUrl: `${baseUrl}/salaries/${data.id}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ [EMAIL] email_notifs non défini pour l\'organisation', orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Générer token et envoyer invitation au salarié (nouveau)
|
// 2. Générer token et envoyer invitation au salarié (nouveau)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { Lock, Key, Mail, Eye, EyeOff, Settings } from "lucide-react";
|
import { Lock, Key, Mail, Eye, EyeOff, Settings, AlertCircle } from "lucide-react";
|
||||||
import styles from "./signin.module.css";
|
import styles from "./signin.module.css";
|
||||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||||
|
import RememberMeInfoModal from "@/components/auth/RememberMeInfoModal";
|
||||||
|
|
||||||
export default function SignIn() {
|
export default function SignIn() {
|
||||||
// Définir le titre de la page
|
// Définir le titre de la page
|
||||||
|
|
@ -18,6 +19,10 @@ export default function SignIn() {
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [info, setInfo] = useState("");
|
const [info, setInfo] = useState("");
|
||||||
|
|
||||||
|
// Remember me
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
const [showRememberMeModal, setShowRememberMeModal] = useState(false);
|
||||||
|
|
||||||
// Détection du mode maintenance staff
|
// Détection du mode maintenance staff
|
||||||
const [isStaffAccess, setIsStaffAccess] = useState(false);
|
const [isStaffAccess, setIsStaffAccess] = useState(false);
|
||||||
|
|
||||||
|
|
@ -205,7 +210,8 @@ export default function SignIn() {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
mfaCode: mfaRequired ? mfaCode : undefined
|
mfaCode: mfaRequired ? mfaCode : undefined,
|
||||||
|
rememberMe
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -278,7 +284,7 @@ export default function SignIn() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ email, code }),
|
body: JSON.stringify({ email, code, rememberMe }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
|
|
@ -513,6 +519,38 @@ export default function SignIn() {
|
||||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Checkbox "Rester connecté" */}
|
||||||
|
<div className={styles.rememberMeSection}>
|
||||||
|
<label className={styles.rememberMeLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
className={styles.rememberMeCheckbox}
|
||||||
|
/>
|
||||||
|
<span className={styles.rememberMeText}>
|
||||||
|
Rester connecté pendant 30 jours
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{rememberMe && (
|
||||||
|
<div className={styles.rememberMeWarning}>
|
||||||
|
<AlertCircle size={14} className={styles.warningIcon} />
|
||||||
|
<span className={styles.warningText}>
|
||||||
|
Recommandé uniquement sur un ordinateur non partagé
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRememberMeModal(true)}
|
||||||
|
className={styles.whyButton}
|
||||||
|
>
|
||||||
|
Pourquoi ?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-sm text-rose-300">{error}</div>}
|
{error && <div className="text-sm text-rose-300">{error}</div>}
|
||||||
{info && <div className="text-sm text-emerald-300">{info}</div>}
|
{info && <div className="text-sm text-emerald-300">{info}</div>}
|
||||||
<button
|
<button
|
||||||
|
|
@ -640,6 +678,37 @@ export default function SignIn() {
|
||||||
{error && <div className="text-sm text-rose-300">{error}</div>}
|
{error && <div className="text-sm text-rose-300">{error}</div>}
|
||||||
{info && <div className="text-sm text-emerald-300">{info}</div>}
|
{info && <div className="text-sm text-emerald-300">{info}</div>}
|
||||||
|
|
||||||
|
{/* Checkbox "Rester connecté" */}
|
||||||
|
<div className={styles.rememberMeSection}>
|
||||||
|
<label className={styles.rememberMeLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
className={styles.rememberMeCheckbox}
|
||||||
|
/>
|
||||||
|
<span className={styles.rememberMeText}>
|
||||||
|
Rester connecté pendant 30 jours
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{rememberMe && (
|
||||||
|
<div className={styles.rememberMeWarning}>
|
||||||
|
<AlertCircle size={14} className={styles.warningIcon} />
|
||||||
|
<span className={styles.warningText}>
|
||||||
|
Recommandé uniquement sur un ordinateur non partagé
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRememberMeModal(true)}
|
||||||
|
className={styles.whyButton}
|
||||||
|
>
|
||||||
|
Pourquoi ?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -706,6 +775,12 @@ export default function SignIn() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal d'information "Rester connecté" */}
|
||||||
|
<RememberMeInfoModal
|
||||||
|
isOpen={showRememberMeModal}
|
||||||
|
onClose={() => setShowRememberMeModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -342,3 +342,113 @@
|
||||||
0% { transform: translate3d(0,0,0); }
|
0% { transform: translate3d(0,0,0); }
|
||||||
100% { transform: translate3d(6px,-10px,0); }
|
100% { transform: translate3d(6px,-10px,0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remember Me Checkbox Styles */
|
||||||
|
.rememberMeSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberMeLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberMeCheckbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #6366f1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberMeText {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #171424;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberMeWarning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(251, 191, 36, 0.12), rgba(245, 158, 11, 0.08));
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningIcon {
|
||||||
|
color: #f59e0b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningText {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whyButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
padding: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whyButton:hover {
|
||||||
|
color: #d97706;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whyButton:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.rememberMeText {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rememberMeWarning {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningText {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whyButton {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
291
components/auth/RememberMeInfoModal.module.css
Normal file
291
components/auth/RememberMeInfoModal.module.css
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalContent {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 24px 16px 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleIcon {
|
||||||
|
color: #3b82f6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBody {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconGreen {
|
||||||
|
color: #10b981;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconRed {
|
||||||
|
color: #ef4444;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconOrange {
|
||||||
|
color: #f59e0b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li {
|
||||||
|
padding: 10px 0;
|
||||||
|
padding-left: 28px;
|
||||||
|
position: relative;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li::before {
|
||||||
|
content: "•";
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li strong {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation:first-child {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation:first-child .recommendationIcon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation:last-child {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation:last-child .recommendationIcon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendationIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation strong {
|
||||||
|
display: block;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityNote {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityNote svg {
|
||||||
|
color: #3b82f6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityNote p {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityNote strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButtonFooter {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButtonFooter:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButtonFooter:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modalContent {
|
||||||
|
max-height: 95vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
padding: 20px 20px 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBody {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
components/auth/RememberMeInfoModal.tsx
Normal file
104
components/auth/RememberMeInfoModal.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X, Shield, AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import styles from "./RememberMeInfoModal.module.css";
|
||||||
|
|
||||||
|
interface RememberMeInfoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RememberMeInfoModal({ isOpen, onClose }: RememberMeInfoModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modalOverlay} onClick={onClose}>
|
||||||
|
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>
|
||||||
|
<Shield className={styles.titleIcon} />
|
||||||
|
<h2>À propos de "Rester connecté"</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className={styles.closeButton} aria-label="Fermer">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalBody}>
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
<CheckCircle2 className={styles.iconGreen} />
|
||||||
|
Avantages
|
||||||
|
</h3>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>Connexion automatique</strong> : Vous restez connecté pendant 30 jours, même après avoir fermé votre navigateur
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Gain de temps</strong> : Plus besoin de vous reconnecter à chaque visite
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Sécurisé</strong> : Vos identifiants restent protégés par des cookies cryptés
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
<XCircle className={styles.iconRed} />
|
||||||
|
Risques sur ordinateur partagé
|
||||||
|
</h3>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>Accès non autorisé</strong> : Toute personne utilisant le même navigateur pourrait accéder à votre compte
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Données sensibles</strong> : Vos bulletins de paie et informations personnelles seraient accessibles
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Session longue durée</strong> : La connexion persiste jusqu'à 30 jours ou déconnexion manuelle
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
<AlertTriangle className={styles.iconOrange} />
|
||||||
|
Nos recommandations
|
||||||
|
</h3>
|
||||||
|
<div className={styles.recommendations}>
|
||||||
|
<div className={styles.recommendation}>
|
||||||
|
<CheckCircle2 className={styles.recommendationIcon} />
|
||||||
|
<div>
|
||||||
|
<strong>Cochez si :</strong>
|
||||||
|
<p>Vous utilisez votre ordinateur personnel ou professionnel non partagé</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.recommendation}>
|
||||||
|
<XCircle className={styles.recommendationIcon} />
|
||||||
|
<div>
|
||||||
|
<strong>Ne cochez pas si :</strong>
|
||||||
|
<p>Vous êtes sur un ordinateur public, partagé, ou dans un cybercafé</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.securityNote}>
|
||||||
|
<Shield size={16} />
|
||||||
|
<p>
|
||||||
|
<strong>Note de sécurité :</strong> Pensez toujours à vous déconnecter manuellement
|
||||||
|
après utilisation sur un ordinateur partagé, même sans avoir coché cette option.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<button onClick={onClose} className={styles.closeButtonFooter}>
|
||||||
|
J'ai compris
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,29 @@ export async function middleware(req: NextRequest) {
|
||||||
const supabase = createMiddlewareClient({ req, res }, { supabaseUrl, supabaseKey: supabaseAnonKey });
|
const supabase = createMiddlewareClient({ req, res }, { supabaseUrl, supabaseKey: supabaseAnonKey });
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
// 1.5) Maintenir les cookies persistants si rememberMe est activé
|
||||||
|
const rememberMeCookie = req.cookies.get("remember_me");
|
||||||
|
if (session && rememberMeCookie?.value === "true") {
|
||||||
|
// Prolonger la durée des cookies Supabase à chaque requête
|
||||||
|
const cookieOptions = {
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 jours
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer tous les cookies Supabase et les renouveler
|
||||||
|
req.cookies.getAll().forEach((cookie) => {
|
||||||
|
if (cookie.name.startsWith("sb-")) {
|
||||||
|
res.cookies.set(cookie.name, cookie.value, cookieOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renouveler aussi le cookie remember_me
|
||||||
|
res.cookies.set("remember_me", "true", cookieOptions);
|
||||||
|
}
|
||||||
|
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
const isStaticFile = /\.[^/]+$/.test(path); // ex: /odentas-logo.png, /robots.txt, etc.
|
const isStaticFile = /\.[^/]+$/.test(path); // ex: /odentas-logo.png, /robots.txt, etc.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue