Procházet zdrojové kódy

Add email verification system (#25)

* new email verification system

* email verifications

* mail verificatino test

* lint
Bernt Christian Egeland před 8 měsíci
rodič
revize
8e6d55970d
54 změnil soubory, kde provedl 1674 přidání a 219 odebrání
  1. 4 1
      messages/de/admin.json
  2. 9 0
      messages/de/auth.json
  3. 7 0
      messages/de/settings.json
  4. 4 1
      messages/en/admin.json
  5. 9 0
      messages/en/auth.json
  6. 7 0
      messages/en/settings.json
  7. 4 1
      messages/es/admin.json
  8. 9 0
      messages/es/auth.json
  9. 7 0
      messages/es/settings.json
  10. 4 1
      messages/fr/admin.json
  11. 9 0
      messages/fr/auth.json
  12. 7 0
      messages/fr/settings.json
  13. 4 1
      messages/it/admin.json
  14. 9 0
      messages/it/auth.json
  15. 7 0
      messages/it/settings.json
  16. 4 1
      messages/nb/admin.json
  17. 9 0
      messages/nb/auth.json
  18. 7 0
      messages/nb/settings.json
  19. 4 1
      messages/nl/admin.json
  20. 9 0
      messages/nl/auth.json
  21. 7 0
      messages/nl/settings.json
  22. 4 1
      messages/pl/admin.json
  23. 9 0
      messages/pl/auth.json
  24. 7 0
      messages/pl/settings.json
  25. 4 1
      messages/pt-BR/admin.json
  26. 9 0
      messages/pt-BR/auth.json
  27. 7 0
      messages/pt-BR/settings.json
  28. 4 1
      messages/tr/admin.json
  29. 9 0
      messages/tr/auth.json
  30. 7 0
      messages/tr/settings.json
  31. 22 1
      src/__tests__/features/auth/acceptInvitation.test.ts
  32. 181 0
      src/__tests__/features/auth/account-actions.test.ts
  33. 165 0
      src/__tests__/features/auth/confirm-email-change.test.ts
  34. 253 0
      src/__tests__/features/auth/send-verification-email.test.ts
  35. 2 2
      src/__tests__/multitenancy/team-invite-isolation.test.ts
  36. 12 0
      src/app/(authenticated)/layout.tsx
  37. 75 7
      src/app/(authenticated)/settings/account/account-settings.tsx
  38. 8 2
      src/app/(authenticated)/settings/account/page.tsx
  39. 7 1
      src/app/(public)/auth/sign-up/page.tsx
  40. 5 1
      src/app/(public)/auth/sign-up/sign-up-form.tsx
  41. 149 0
      src/app/(public)/auth/verify-email/page.tsx
  42. 104 0
      src/app/api/public/confirm-email-change/route.ts
  43. 27 9
      src/features/admin/Actions/deleteUser.ts
  44. 2 0
      src/features/admin/Actions/getUsers.ts
  45. 17 0
      src/features/admin/Components/admin-settings.tsx
  46. 14 4
      src/features/admin/Components/admin-users.tsx
  47. 3 0
      src/features/admin/Schema/systemSettingsSchema.ts
  48. 89 1
      src/features/settings/Actions/accountActions.ts
  49. 26 147
      src/features/settings/Actions/deleteAccount.ts
  50. 5 1
      src/features/team/Actions/acceptInvitation.ts
  51. 49 0
      src/hooks/use-cooldown.ts
  52. 101 31
      src/lib/auth.ts
  53. 151 0
      src/lib/delete-user-data.ts
  54. 7 2
      src/lib/get-layout-data.ts

+ 4 - 1
messages/de/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Benutzer konnte nicht gelöscht werden",
     "promoteToSuperAdmin": "Zum Super-Admin befördern",
     "demoteFromSuperAdmin": "Vom Super-Admin herabstufen",
-    "deleteUser": "Benutzer löschen"
+    "deleteUser": "Benutzer löschen",
+    "emailVerified": "E-Mail verifiziert"
   },
   "organizations": {
     "searchPlaceholder": "Nach Organisationsname suchen...",
@@ -68,6 +69,8 @@
     "platformDescription": "Allgemeine Plattformkonfigurationsoptionen",
     "disableRegistration": "Registrierung deaktivieren",
     "disableRegistrationHint": "Neue Benutzerregistrierungen auf der Plattform blockieren",
+    "requireEmailVerification": "E-Mail-Verifizierung erforderlich",
+    "requireEmailVerificationHint": "Neue Benutzer müssen ihre E-Mail-Adresse bestätigen, bevor sie auf die Plattform zugreifen können",
     "emailTitle": "E-Mail-Einstellungen",
     "emailDescription": "Plattform-E-Mail-Anbieter — Wird für systemweite E-Mails wie Passwortzurücksetzungen und Teameinladungen verwendet. Dies ist auch der Standard-E-Mail-Anbieter für alle Organisationen. Organisationen können optional ihren eigenen E-Mail-Anbieter unter Einstellungen > E-Mail konfigurieren.",
     "smtpHost": "SMTP-Host",

+ 9 - 0
messages/de/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Das Passwort konnte nicht zurückgesetzt werden. Der Link ist möglicherweise abgelaufen."
     }
   },
+  "verifyEmail": {
+    "title": "E-Mail bestätigen",
+    "description": "Wir haben einen Bestätigungslink an {email} gesendet. Bitte überprüfen Sie Ihren Posteingang und klicken Sie auf den Link, um Ihre E-Mail-Adresse zu bestätigen.",
+    "resend": "Bestätigungs-E-Mail erneut senden",
+    "resent": "Bestätigungs-E-Mail gesendet. Überprüfen Sie Ihren Posteingang.",
+    "signOut": "Abmelden",
+    "resendCooldown": "Erneut senden in {seconds}s",
+    "checkSpam": "Wenn Sie die E-Mail nicht sehen, überprüfen Sie Ihren Spam-Ordner."
+  },
   "verify2fa": {
     "title": "Zwei-Faktor-Authentifizierung",
     "descriptionTotp": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",

+ 7 - 0
messages/de/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profil",
     "profileDescription": "Aktualisieren Sie Ihren Anzeigenamen und Ihre E-Mail-Adresse.",
+    "emailVerified": "Bestätigt",
+    "emailNotVerified": "Nicht bestätigt",
+    "sendVerificationEmail": "Bestätigungs-E-Mail senden",
+    "verificationEmailSent": "Bestätigungs-E-Mail gesendet. Überprüfen Sie Ihren Posteingang.",
+    "verificationEmailFailed": "Bestätigungs-E-Mail konnte nicht gesendet werden",
+    "resendCooldown": "Erneut senden in {seconds}s",
+    "emailChangeSent": "Ein Bestätigungslink wurde an Ihre neue E-Mail-Adresse gesendet. Ihre E-Mail wird aktualisiert, sobald Sie auf den Link klicken.",
     "name": "Name",
     "namePlaceholder": "Ihr Name",
     "emailLabel": "E-Mail",

+ 4 - 1
messages/en/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Failed to delete user",
     "promoteToSuperAdmin": "Promote to Super Admin",
     "demoteFromSuperAdmin": "Demote from Super Admin",
-    "deleteUser": "Delete User"
+    "deleteUser": "Delete User",
+    "emailVerified": "Email verified"
   },
   "organizations": {
     "searchPlaceholder": "Search by organization name...",
@@ -68,6 +69,8 @@
     "platformDescription": "General platform configuration options",
     "disableRegistration": "Disable Registration",
     "disableRegistrationHint": "Block new user sign-ups on the platform",
+    "requireEmailVerification": "Require Email Verification",
+    "requireEmailVerificationHint": "New users must verify their email address before accessing the platform",
     "emailTitle": "Email Settings",
     "emailDescription": "Platform Email Provider — Used for system-level emails such as password resets and team invitations. This is also the default email provider for all organizations. Organizations can optionally configure their own email provider in Settings > Email.",
     "smtpHost": "SMTP Host",

+ 9 - 0
messages/en/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Could not reset password. The link may have expired."
     }
   },
+  "verifyEmail": {
+    "title": "Verify your email",
+    "description": "We sent a verification link to {email}. Please check your inbox and click the link to verify your email address.",
+    "resend": "Resend verification email",
+    "resent": "Verification email sent. Check your inbox.",
+    "signOut": "Sign out",
+    "resendCooldown": "Resend in {seconds}s",
+    "checkSpam": "If you don't see the email, check your spam folder."
+  },
   "verify2fa": {
     "title": "Two-Factor Authentication",
     "descriptionTotp": "Enter the 6-digit code from your authenticator app",

+ 7 - 0
messages/en/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profile",
     "profileDescription": "Update your display name and email address.",
+    "emailVerified": "Verified",
+    "emailNotVerified": "Not verified",
+    "sendVerificationEmail": "Send verification email",
+    "verificationEmailSent": "Verification email sent. Check your inbox.",
+    "verificationEmailFailed": "Failed to send verification email",
+    "resendCooldown": "Resend in {seconds}s",
+    "emailChangeSent": "A confirmation link has been sent to your new email address. Your email will be updated once you click the link.",
     "name": "Name",
     "namePlaceholder": "Your name",
     "emailLabel": "Email",

+ 4 - 1
messages/es/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Error al eliminar el usuario",
     "promoteToSuperAdmin": "Promover a superadministrador",
     "demoteFromSuperAdmin": "Degradar de superadministrador",
-    "deleteUser": "Eliminar usuario"
+    "deleteUser": "Eliminar usuario",
+    "emailVerified": "Correo verificado"
   },
   "organizations": {
     "searchPlaceholder": "Buscar por nombre de organización...",
@@ -68,6 +69,8 @@
     "platformDescription": "Opciones generales de configuración de la plataforma",
     "disableRegistration": "Desactivar registro",
     "disableRegistrationHint": "Bloquear nuevos registros de usuarios en la plataforma",
+    "requireEmailVerification": "Requerir verificación de correo electrónico",
+    "requireEmailVerificationHint": "Los nuevos usuarios deben verificar su dirección de correo electrónico antes de acceder a la plataforma",
     "emailTitle": "Configuración de correo electrónico",
     "emailDescription": "Proveedor de correo electrónico de la plataforma — Se utiliza para correos electrónicos a nivel del sistema, como restablecimiento de contraseñas e invitaciones de equipo. Este es también el proveedor de correo electrónico predeterminado para todas las organizaciones. Las organizaciones pueden configurar opcionalmente su propio proveedor de correo electrónico en Configuración > Correo electrónico.",
     "smtpHost": "Host SMTP",

+ 9 - 0
messages/es/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "No se pudo restablecer la contrasena. Es posible que el enlace haya expirado."
     }
   },
+  "verifyEmail": {
+    "title": "Verifique su correo electrónico",
+    "description": "Hemos enviado un enlace de verificación a {email}. Revise su bandeja de entrada y haga clic en el enlace para verificar su dirección de correo electrónico.",
+    "resend": "Reenviar correo de verificación",
+    "resent": "Correo de verificación enviado. Revise su bandeja de entrada.",
+    "signOut": "Cerrar sesión",
+    "resendCooldown": "Reenviar en {seconds}s",
+    "checkSpam": "Si no ve el correo, revise su carpeta de spam."
+  },
   "verify2fa": {
     "title": "Autenticacion de dos factores",
     "descriptionTotp": "Ingrese el codigo de 6 digitos de su aplicacion de autenticacion",

+ 7 - 0
messages/es/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Perfil",
     "profileDescription": "Actualice su nombre para mostrar y direccion de correo electronico.",
+    "emailVerified": "Verificado",
+    "emailNotVerified": "No verificado",
+    "sendVerificationEmail": "Enviar correo de verificación",
+    "verificationEmailSent": "Correo de verificación enviado. Revise su bandeja de entrada.",
+    "verificationEmailFailed": "No se pudo enviar el correo de verificación",
+    "resendCooldown": "Reenviar en {seconds}s",
+    "emailChangeSent": "Se ha enviado un enlace de confirmación a su nueva dirección de correo electrónico. Su correo se actualizará cuando haga clic en el enlace.",
     "name": "Nombre",
     "namePlaceholder": "Su nombre",
     "emailLabel": "Correo electronico",

+ 4 - 1
messages/fr/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Échec de la suppression de l'utilisateur",
     "promoteToSuperAdmin": "Promouvoir en super administrateur",
     "demoteFromSuperAdmin": "Rétrograder du super administrateur",
-    "deleteUser": "Supprimer l'utilisateur"
+    "deleteUser": "Supprimer l'utilisateur",
+    "emailVerified": "E-mail vérifié"
   },
   "organizations": {
     "searchPlaceholder": "Rechercher par nom d'organisation...",
@@ -68,6 +69,8 @@
     "platformDescription": "Options générales de configuration de la plateforme",
     "disableRegistration": "Désactiver l'inscription",
     "disableRegistrationHint": "Bloquer les nouvelles inscriptions d'utilisateurs sur la plateforme",
+    "requireEmailVerification": "Exiger la vérification de l'e-mail",
+    "requireEmailVerificationHint": "Les nouveaux utilisateurs doivent vérifier leur adresse e-mail avant d'accéder à la plateforme",
     "emailTitle": "Paramètres e-mail",
     "emailDescription": "Fournisseur d'e-mail de la plateforme — Utilisé pour les e-mails au niveau du système tels que les réinitialisations de mot de passe et les invitations d'équipe. C'est également le fournisseur d'e-mail par défaut pour toutes les organisations. Les organisations peuvent éventuellement configurer leur propre fournisseur d'e-mail dans Paramètres > E-mail.",
     "smtpHost": "Hôte SMTP",

+ 9 - 0
messages/fr/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Impossible de réinitialiser le mot de passe. Le lien a peut-être expiré."
     }
   },
+  "verifyEmail": {
+    "title": "Vérifiez votre e-mail",
+    "description": "Nous avons envoyé un lien de vérification à {email}. Veuillez vérifier votre boîte de réception et cliquer sur le lien pour confirmer votre adresse e-mail.",
+    "resend": "Renvoyer l'e-mail de vérification",
+    "resent": "E-mail de vérification envoyé. Vérifiez votre boîte de réception.",
+    "signOut": "Se déconnecter",
+    "resendCooldown": "Renvoyer dans {seconds}s",
+    "checkSpam": "Si vous ne voyez pas l'e-mail, vérifiez votre dossier spam."
+  },
   "verify2fa": {
     "title": "Authentification à deux facteurs",
     "descriptionTotp": "Saisissez le code à 6 chiffres de votre application d'authentification",

+ 7 - 0
messages/fr/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profil",
     "profileDescription": "Mettez a jour votre nom d'affichage et votre adresse e-mail.",
+    "emailVerified": "Vérifié",
+    "emailNotVerified": "Non vérifié",
+    "sendVerificationEmail": "Envoyer l'e-mail de vérification",
+    "verificationEmailSent": "E-mail de vérification envoyé. Vérifiez votre boîte de réception.",
+    "verificationEmailFailed": "Échec de l'envoi de l'e-mail de vérification",
+    "resendCooldown": "Renvoyer dans {seconds}s",
+    "emailChangeSent": "Un lien de confirmation a été envoyé à votre nouvelle adresse e-mail. Votre e-mail sera mis à jour lorsque vous cliquerez sur le lien.",
     "name": "Nom",
     "namePlaceholder": "Votre nom",
     "emailLabel": "E-mail",

+ 4 - 1
messages/it/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Eliminazione utente non riuscita",
     "promoteToSuperAdmin": "Promuovi a super amministratore",
     "demoteFromSuperAdmin": "Retrocedi da super amministratore",
-    "deleteUser": "Elimina utente"
+    "deleteUser": "Elimina utente",
+    "emailVerified": "Email verificata"
   },
   "organizations": {
     "searchPlaceholder": "Cerca per nome organizzazione...",
@@ -68,6 +69,8 @@
     "platformDescription": "Opzioni generali di configurazione della piattaforma",
     "disableRegistration": "Disabilita registrazione",
     "disableRegistrationHint": "Blocca le nuove registrazioni degli utenti sulla piattaforma",
+    "requireEmailVerification": "Richiedi verifica email",
+    "requireEmailVerificationHint": "I nuovi utenti devono verificare il proprio indirizzo email prima di accedere alla piattaforma",
     "emailTitle": "Impostazioni email",
     "emailDescription": "Provider email della piattaforma — Utilizzato per le email a livello di sistema come il ripristino della password e gli inviti al team. Questo è anche il provider email predefinito per tutte le organizzazioni. Le organizzazioni possono configurare opzionalmente il proprio provider email in Impostazioni > Email.",
     "smtpHost": "Host SMTP",

+ 9 - 0
messages/it/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Impossibile reimpostare la password. Il link potrebbe essere scaduto."
     }
   },
+  "verifyEmail": {
+    "title": "Verifica la tua email",
+    "description": "Abbiamo inviato un link di verifica a {email}. Controlla la tua casella di posta e clicca sul link per verificare il tuo indirizzo email.",
+    "resend": "Reinvia email di verifica",
+    "resent": "Email di verifica inviata. Controlla la tua casella di posta.",
+    "signOut": "Esci",
+    "resendCooldown": "Reinvia tra {seconds}s",
+    "checkSpam": "Se non vedi l'email, controlla la cartella spam."
+  },
   "verify2fa": {
     "title": "Autenticazione a due fattori",
     "descriptionTotp": "Inserisca il codice a 6 cifre dalla Sua app di autenticazione",

+ 7 - 0
messages/it/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profilo",
     "profileDescription": "Aggiorna il tuo nome visualizzato e indirizzo email.",
+    "emailVerified": "Verificato",
+    "emailNotVerified": "Non verificato",
+    "sendVerificationEmail": "Invia email di verifica",
+    "verificationEmailSent": "Email di verifica inviata. Controlla la tua casella di posta.",
+    "verificationEmailFailed": "Impossibile inviare l'email di verifica",
+    "resendCooldown": "Reinvia tra {seconds}s",
+    "emailChangeSent": "Un link di conferma è stato inviato al tuo nuovo indirizzo email. La tua email verrà aggiornata quando cliccherai sul link.",
     "name": "Nome",
     "namePlaceholder": "Il tuo nome",
     "emailLabel": "Email",

+ 4 - 1
messages/nb/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Kunne ikke slette bruker",
     "promoteToSuperAdmin": "Forfrem til superadmin",
     "demoteFromSuperAdmin": "Degrader fra superadmin",
-    "deleteUser": "Slett bruker"
+    "deleteUser": "Slett bruker",
+    "emailVerified": "E-post verifisert"
   },
   "organizations": {
     "searchPlaceholder": "Søk etter organisasjonsnavn...",
@@ -68,6 +69,8 @@
     "platformDescription": "Generelle konfigurasjonsalternativer for plattformen",
     "disableRegistration": "Deaktiver registrering",
     "disableRegistrationHint": "Blokker nye brukerregistreringer på plattformen",
+    "requireEmailVerification": "Krev e-postbekreftelse",
+    "requireEmailVerificationHint": "Nye brukere må bekrefte e-postadressen sin før de får tilgang til plattformen",
     "emailTitle": "E-postinnstillinger",
     "emailDescription": "Plattformens e-postleverandør — Brukes for e-poster på systemnivå som tilbakestilling av passord og teaminvitasjoner. Dette er også standard e-postleverandør for alle organisasjoner. Organisasjoner kan valgfritt konfigurere sin egen e-postleverandør under Innstillinger > E-post.",
     "smtpHost": "SMTP-vert",

+ 9 - 0
messages/nb/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Kunne ikke tilbakestille passordet. Lenken kan ha utløpt."
     }
   },
+  "verifyEmail": {
+    "title": "Bekreft e-posten din",
+    "description": "Vi har sendt en bekreftelseslenke til {email}. Vennligst sjekk innboksen din og klikk på lenken for å bekrefte e-postadressen din.",
+    "resend": "Send bekreftelses-e-post på nytt",
+    "resent": "Bekreftelses-e-post sendt. Sjekk innboksen din.",
+    "signOut": "Logg ut",
+    "resendCooldown": "Send på nytt om {seconds}s",
+    "checkSpam": "Hvis du ikke ser e-posten, sjekk spam-mappen din."
+  },
   "verify2fa": {
     "title": "Tofaktorautentisering",
     "descriptionTotp": "Skriv inn den 6-sifrede koden fra autentiseringsappen din",

+ 7 - 0
messages/nb/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profil",
     "profileDescription": "Oppdater visningsnavnet og e-postadressen din.",
+    "emailVerified": "Bekreftet",
+    "emailNotVerified": "Ikke bekreftet",
+    "sendVerificationEmail": "Send bekreftelses-e-post",
+    "verificationEmailSent": "Bekreftelses-e-post sendt. Sjekk innboksen din.",
+    "verificationEmailFailed": "Kunne ikke sende bekreftelses-e-post",
+    "resendCooldown": "Send på nytt om {seconds}s",
+    "emailChangeSent": "En bekreftelseslenke er sendt til din nye e-postadresse. E-posten din vil bli oppdatert når du klikker på lenken.",
     "name": "Navn",
     "namePlaceholder": "Ditt navn",
     "emailLabel": "E-post",

+ 4 - 1
messages/nl/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Gebruiker verwijderen mislukt",
     "promoteToSuperAdmin": "Promoveren tot superbeheerder",
     "demoteFromSuperAdmin": "Degraderen als superbeheerder",
-    "deleteUser": "Gebruiker verwijderen"
+    "deleteUser": "Gebruiker verwijderen",
+    "emailVerified": "E-mail geverifieerd"
   },
   "organizations": {
     "searchPlaceholder": "Zoeken op organisatienaam...",
@@ -68,6 +69,8 @@
     "platformDescription": "Algemene platformconfiguratie-opties",
     "disableRegistration": "Registratie uitschakelen",
     "disableRegistrationHint": "Nieuwe gebruikersregistraties op het platform blokkeren",
+    "requireEmailVerification": "E-mailverificatie vereisen",
+    "requireEmailVerificationHint": "Nieuwe gebruikers moeten hun e-mailadres verifiëren voordat ze toegang krijgen tot het platform",
     "emailTitle": "E-mailinstellingen",
     "emailDescription": "Platform e-mailprovider — Wordt gebruikt voor e-mails op systeemniveau, zoals wachtwoordresets en teamuitnodigingen. Dit is ook de standaard e-mailprovider voor alle organisaties. Organisaties kunnen optioneel hun eigen e-mailprovider configureren in Instellingen > E-mail.",
     "smtpHost": "SMTP Host",

+ 9 - 0
messages/nl/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Kon wachtwoord niet herstellen. De koppeling is mogelijk verlopen."
     }
   },
+  "verifyEmail": {
+    "title": "Verifieer uw e-mail",
+    "description": "We hebben een verificatielink naar {email} gestuurd. Controleer uw postvak en klik op de link om uw e-mailadres te verifiëren.",
+    "resend": "Verificatie-e-mail opnieuw verzenden",
+    "resent": "Verificatie-e-mail verzonden. Controleer uw postvak.",
+    "signOut": "Uitloggen",
+    "resendCooldown": "Opnieuw verzenden over {seconds}s",
+    "checkSpam": "Als u de e-mail niet ziet, controleer dan uw spammap."
+  },
   "verify2fa": {
     "title": "Tweefactorauthenticatie",
     "descriptionTotp": "Voer de 6-cijferige code in vanuit uw authenticator-app",

+ 7 - 0
messages/nl/settings.json

@@ -62,6 +62,13 @@
   "account": {
     "profileTitle": "Profiel",
     "profileDescription": "Werk uw weergavenaam en e-mailadres bij.",
+    "emailVerified": "Geverifieerd",
+    "emailNotVerified": "Niet geverifieerd",
+    "sendVerificationEmail": "Verificatie-e-mail verzenden",
+    "verificationEmailSent": "Verificatie-e-mail verzonden. Controleer uw postvak.",
+    "verificationEmailFailed": "Kon verificatie-e-mail niet verzenden",
+    "resendCooldown": "Opnieuw verzenden over {seconds}s",
+    "emailChangeSent": "Er is een bevestigingslink naar uw nieuwe e-mailadres gestuurd. Uw e-mail wordt bijgewerkt zodra u op de link klikt.",
     "name": "Naam", "namePlaceholder": "Uw naam",
     "emailLabel": "E-mail", "emailPlaceholder": "Uw e-mail",
     "saveProfile": "Profiel opslaan", "profileUpdated": "Profiel bijgewerkt",

+ 4 - 1
messages/pl/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Nie udało się usunąć użytkownika",
     "promoteToSuperAdmin": "Awansuj na superadmina",
     "demoteFromSuperAdmin": "Degraduj z superadmina",
-    "deleteUser": "Usuń użytkownika"
+    "deleteUser": "Usuń użytkownika",
+    "emailVerified": "E-mail zweryfikowany"
   },
   "organizations": {
     "searchPlaceholder": "Szukaj po nazwie organizacji...",
@@ -68,6 +69,8 @@
     "platformDescription": "Ogólne opcje konfiguracji platformy",
     "disableRegistration": "Wyłącz rejestrację",
     "disableRegistrationHint": "Zablokuj rejestrację nowych użytkowników na platformie",
+    "requireEmailVerification": "Wymagaj weryfikacji e-mail",
+    "requireEmailVerificationHint": "Nowi użytkownicy muszą zweryfikować swój adres e-mail przed uzyskaniem dostępu do platformy",
     "emailTitle": "Ustawienia e-mail",
     "emailDescription": "Dostawca e-mail platformy — Używany do e-maili systemowych, takich jak resetowanie hasła i zaproszenia do zespołu. Jest to również domyślny dostawca e-mail dla wszystkich organizacji. Organizacje mogą opcjonalnie skonfigurować własnego dostawcę e-mail w Ustawienia > E-mail.",
     "smtpHost": "Host SMTP",

+ 9 - 0
messages/pl/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Nie udało się zresetować hasła. Link mógł wygasnąć."
     }
   },
+  "verifyEmail": {
+    "title": "Zweryfikuj swój e-mail",
+    "description": "Wysłaliśmy link weryfikacyjny na adres {email}. Sprawdź swoją skrzynkę odbiorczą i kliknij link, aby zweryfikować swój adres e-mail.",
+    "resend": "Wyślij ponownie e-mail weryfikacyjny",
+    "resent": "E-mail weryfikacyjny wysłany. Sprawdź swoją skrzynkę odbiorczą.",
+    "signOut": "Wyloguj się",
+    "resendCooldown": "Wyślij ponownie za {seconds}s",
+    "checkSpam": "Jeśli nie widzisz e-maila, sprawdź folder spam."
+  },
   "verify2fa": {
     "title": "Uwierzytelnianie dwuskładnikowe",
     "descriptionTotp": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",

+ 7 - 0
messages/pl/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profil",
     "profileDescription": "Zaktualizuj swoja nazwe wyswietlana i adres e-mail.",
+    "emailVerified": "Zweryfikowany",
+    "emailNotVerified": "Niezweryfikowany",
+    "sendVerificationEmail": "Wyślij e-mail weryfikacyjny",
+    "verificationEmailSent": "E-mail weryfikacyjny wysłany. Sprawdź swoją skrzynkę odbiorczą.",
+    "verificationEmailFailed": "Nie udało się wysłać e-maila weryfikacyjnego",
+    "resendCooldown": "Wyślij ponownie za {seconds}s",
+    "emailChangeSent": "Link potwierdzający został wysłany na Twój nowy adres e-mail. Twój e-mail zostanie zaktualizowany po kliknięciu w link.",
     "name": "Nazwa",
     "namePlaceholder": "Twoja nazwa",
     "emailLabel": "E-mail",

+ 4 - 1
messages/pt-BR/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Falha ao excluir usuário",
     "promoteToSuperAdmin": "Promover a Super Admin",
     "demoteFromSuperAdmin": "Rebaixar de Super Admin",
-    "deleteUser": "Excluir Usuário"
+    "deleteUser": "Excluir Usuário",
+    "emailVerified": "E-mail verificado"
   },
   "organizations": {
     "searchPlaceholder": "Buscar por nome da organização...",
@@ -68,6 +69,8 @@
     "platformDescription": "Opções gerais de configuração da plataforma",
     "disableRegistration": "Desativar Registro",
     "disableRegistrationHint": "Bloquear novos cadastros de usuários na plataforma",
+    "requireEmailVerification": "Exigir verificação de e-mail",
+    "requireEmailVerificationHint": "Novos usuários devem verificar seu endereço de e-mail antes de acessar a plataforma",
     "emailTitle": "Configurações de E-mail",
     "emailDescription": "Provedor de E-mail da Plataforma — Usado para e-mails em nível de sistema, como redefinição de senha e convites de equipe. Este também é o provedor de e-mail padrão para todas as organizações. As organizações podem opcionalmente configurar seu próprio provedor de e-mail em Configurações > E-mail.",
     "smtpHost": "Host SMTP",

+ 9 - 0
messages/pt-BR/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "N\u00e3o foi poss\u00edvel redefinir a senha. O link pode ter expirado."
     }
   },
+  "verifyEmail": {
+    "title": "Verifique seu e-mail",
+    "description": "Enviamos um link de verificação para {email}. Verifique sua caixa de entrada e clique no link para confirmar seu endereço de e-mail.",
+    "resend": "Reenviar e-mail de verificação",
+    "resent": "E-mail de verificação enviado. Verifique sua caixa de entrada.",
+    "signOut": "Sair",
+    "resendCooldown": "Reenviar em {seconds}s",
+    "checkSpam": "Se você não encontrar o e-mail, verifique sua pasta de spam."
+  },
   "verify2fa": {
     "title": "Autentica\u00e7\u00e3o de Dois Fatores",
     "descriptionTotp": "Digite o c\u00f3digo de 6 d\u00edgitos do seu aplicativo autenticador",

+ 7 - 0
messages/pt-BR/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Perfil",
     "profileDescription": "Atualize seu nome de exibicao e endereco de e-mail.",
+    "emailVerified": "Verificado",
+    "emailNotVerified": "Não verificado",
+    "sendVerificationEmail": "Enviar e-mail de verificação",
+    "verificationEmailSent": "E-mail de verificação enviado. Verifique sua caixa de entrada.",
+    "verificationEmailFailed": "Falha ao enviar e-mail de verificação",
+    "resendCooldown": "Reenviar em {seconds}s",
+    "emailChangeSent": "Um link de confirmação foi enviado para seu novo endereço de e-mail. Seu e-mail será atualizado quando você clicar no link.",
     "name": "Nome",
     "namePlaceholder": "Seu nome",
     "emailLabel": "E-mail",

+ 4 - 1
messages/tr/admin.json

@@ -44,7 +44,8 @@
     "failedDelete": "Kullanıcı silinemedi",
     "promoteToSuperAdmin": "Süper Yöneticiye Yükselt",
     "demoteFromSuperAdmin": "Süper Yöneticilikten Düşür",
-    "deleteUser": "Kullanıcıyı Sil"
+    "deleteUser": "Kullanıcıyı Sil",
+    "emailVerified": "E-posta doğrulandı"
   },
   "organizations": {
     "searchPlaceholder": "Organizasyon adına göre ara...",
@@ -68,6 +69,8 @@
     "platformDescription": "Genel platform yapılandırma seçenekleri",
     "disableRegistration": "Kayıt Olmayı Devre Dışı Bırak",
     "disableRegistrationHint": "Platformda yeni kullanıcı kayıtlarını engelle",
+    "requireEmailVerification": "E-posta Doğrulaması Gerektir",
+    "requireEmailVerificationHint": "Yeni kullanıcılar platforma erişmeden önce e-posta adreslerini doğrulamalıdır",
     "emailTitle": "E-posta Ayarları",
     "emailDescription": "Platform E-posta Sağlayıcısı — Parola sıfırlama ve ekip davetleri gibi sistem düzeyindeki e-postalar için kullanılır. Bu aynı zamanda tüm organizasyonlar için varsayılan e-posta sağlayıcısıdır. Organizasyonlar isteğe bağlı olarak Ayarlar > E-posta bölümünden kendi e-posta sağlayıcılarını yapılandırabilir.",
     "smtpHost": "SMTP Sunucusu",

+ 9 - 0
messages/tr/auth.json

@@ -58,6 +58,15 @@
       "resetFailed": "Şifre sıfırlanamadı. Bağlantının süresi dolmuş olabilir."
     }
   },
+  "verifyEmail": {
+    "title": "E-postanızı doğrulayın",
+    "description": "{email} adresine bir doğrulama bağlantısı gönderdik. Lütfen gelen kutunuzu kontrol edin ve e-posta adresinizi doğrulamak için bağlantıya tıklayın.",
+    "resend": "Doğrulama e-postasını yeniden gönder",
+    "resent": "Doğrulama e-postası gönderildi. Gelen kutunuzu kontrol edin.",
+    "signOut": "Çıkış yap",
+    "resendCooldown": "{seconds}s sonra yeniden gönder",
+    "checkSpam": "E-postayı göremiyorsanız spam klasörünüzü kontrol edin."
+  },
   "verify2fa": {
     "title": "İki Faktörlü Kimlik Doğrulama",
     "descriptionTotp": "Kimlik doğrulama uygulamanızdaki 6 haneli kodu girin",

+ 7 - 0
messages/tr/settings.json

@@ -72,6 +72,13 @@
   "account": {
     "profileTitle": "Profil",
     "profileDescription": "Görünen adınızı ve e-posta adresinizi güncelleyin.",
+    "emailVerified": "Doğrulandı",
+    "emailNotVerified": "Doğrulanmadı",
+    "sendVerificationEmail": "Doğrulama e-postası gönder",
+    "verificationEmailSent": "Doğrulama e-postası gönderildi. Gelen kutunuzu kontrol edin.",
+    "verificationEmailFailed": "Doğrulama e-postası gönderilemedi",
+    "resendCooldown": "{seconds}s sonra yeniden gönder",
+    "emailChangeSent": "Yeni e-posta adresinize bir onay bağlantısı gönderildi. Bağlantıya tıkladığınızda e-postanız güncellenecektir.",
     "name": "Ad",
     "namePlaceholder": "Adınız",
     "emailLabel": "E-posta",

+ 22 - 1
src/__tests__/features/auth/acceptInvitation.test.ts

@@ -29,6 +29,9 @@ vi.mock("@/lib/db", () => ({
       findFirst: vi.fn(),
       create: vi.fn(),
     },
+    user: {
+      update: vi.fn(),
+    },
     $transaction: vi.fn(),
   },
 }));
@@ -118,13 +121,31 @@ describe("acceptInvitation", () => {
     mockGetSession.mockResolvedValue(SESSION as any);
     mockFindInvitation.mockResolvedValue(VALID_INVITATION as any);
     mockFindMembership.mockResolvedValue(null);
-    mockTransaction.mockResolvedValue([{}, {}] as any);
+    mockTransaction.mockResolvedValue([{}, {}, {}] as any);
 
     const result = await acceptInvitation({ token: "tok-abc" });
     expect(result).toEqual({ success: true, data: { accepted: true } });
     expect(mockTransaction).toHaveBeenCalled();
   });
 
+  it("sets emailVerified to true in the transaction", async () => {
+    mockGetSession.mockResolvedValue(SESSION as any);
+    mockFindInvitation.mockResolvedValue(VALID_INVITATION as any);
+    mockFindMembership.mockResolvedValue(null);
+    mockTransaction.mockResolvedValue([{}, {}, {}] as any);
+
+    await acceptInvitation({ token: "tok-abc" });
+
+    // The transaction should be called with an array containing the user.update call
+    const transactionArg = mockTransaction.mock.calls[0][0];
+    expect(transactionArg).toHaveLength(3);
+    // Verify db.user.update was called with emailVerified: true
+    expect(db.user.update).toHaveBeenCalledWith({
+      where: { id: SESSION.user.id },
+      data: { emailVerified: true },
+    });
+  });
+
   it("returns error when transaction throws", async () => {
     mockGetSession.mockResolvedValue(SESSION as any);
     mockFindInvitation.mockResolvedValue(VALID_INVITATION as any);

+ 181 - 0
src/__tests__/features/auth/account-actions.test.ts

@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Mock cached-session for withAuth
+vi.mock("@/lib/cached-session", () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}));
+
+// Mock db
+vi.mock("@/lib/db", () => ({
+  db: {
+    user: {
+      findUnique: vi.fn(),
+      findFirst: vi.fn(),
+      update: vi.fn(),
+    },
+    verification: {
+      upsert: vi.fn(),
+    },
+  },
+}));
+
+// Mock email
+vi.mock("@/lib/email", () => ({
+  sendMail: vi.fn().mockResolvedValue(undefined),
+  getFromAddress: vi.fn().mockResolvedValue("noreply@test.com"),
+}));
+
+import { updateEmail, requestEmailChange } from "@/features/settings/Actions/accountActions";
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { db } from "@/lib/db";
+
+const mockGetCachedSession = vi.mocked(getCachedSession);
+const mockGetCachedMembership = vi.mocked(getCachedMembership);
+const mockFindUnique = vi.mocked(db.user.findUnique);
+const mockFindFirst = vi.mocked(db.user.findFirst);
+const mockUpdate = vi.mocked(db.user.update);
+const mockUpsert = vi.mocked(db.verification.upsert);
+
+const SESSION = { user: { id: "user-1", email: "user@example.com" } };
+const MEMBERSHIP = {
+  organizationId: "org-1",
+  role: "admin",
+  roleId: null,
+  customRole: null,
+};
+
+function setupAuth() {
+  mockGetCachedSession.mockResolvedValue(SESSION as any);
+  mockFindUnique.mockResolvedValue({ isSuperAdmin: false } as any);
+  mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
+}
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+describe("updateEmail", () => {
+  it("returns Unauthorized when no session", async () => {
+    mockGetCachedSession.mockResolvedValue(null);
+    const result = await updateEmail({ email: "new@example.com" });
+    expect(result).toEqual({ success: false, error: "Unauthorized" });
+  });
+
+  it("returns validation error for invalid email", async () => {
+    setupAuth();
+    const result = await updateEmail({ email: "not-an-email" });
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("email");
+  });
+
+  it("returns error when email is already in use", async () => {
+    setupAuth();
+    mockFindFirst.mockResolvedValue({ id: "other-user" } as any);
+    const result = await updateEmail({ email: "taken@example.com" });
+    expect(result).toEqual({ success: false, error: "Email is already in use" });
+  });
+
+  it("updates email and sets emailVerified=false on success", async () => {
+    setupAuth();
+    mockFindFirst.mockResolvedValue(null);
+    mockUpdate.mockResolvedValue({} as any);
+
+    const result = await updateEmail({ email: "new@example.com" });
+    expect(result).toEqual({ success: true, data: { email: "new@example.com" } });
+    expect(mockUpdate).toHaveBeenCalledWith({
+      where: { id: "user-1" },
+      data: { email: "new@example.com", emailVerified: false },
+    });
+  });
+});
+
+describe("requestEmailChange", () => {
+  it("returns Unauthorized when no session", async () => {
+    mockGetCachedSession.mockResolvedValue(null);
+    const result = await requestEmailChange({ email: "new@example.com" });
+    expect(result).toEqual({ success: false, error: "Unauthorized" });
+  });
+
+  it("returns validation error for invalid email", async () => {
+    setupAuth();
+    const result = await requestEmailChange({ email: "bad-email" });
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("email");
+  });
+
+  it("returns error when email is already in use", async () => {
+    setupAuth();
+    mockFindFirst.mockResolvedValue({ id: "other-user" } as any);
+    const result = await requestEmailChange({ email: "taken@example.com" });
+    expect(result).toEqual({ success: false, error: "Email is already in use" });
+  });
+
+  it("returns error when user not found", async () => {
+    setupAuth();
+    mockFindFirst.mockResolvedValue(null);
+    // findUnique is called twice: once by withAuth (isSuperAdmin), once by requestEmailChange (user lookup)
+    // First call returns isSuperAdmin check, second returns null for user
+    mockFindUnique
+      .mockResolvedValueOnce({ isSuperAdmin: false } as any)
+      .mockResolvedValueOnce(null);
+    const result = await requestEmailChange({ email: "new@example.com" });
+    expect(result).toEqual({ success: false, error: "User not found" });
+  });
+
+  it("generates token, stores hash, and sends email to the new address", async () => {
+    setupAuth();
+    mockFindFirst.mockResolvedValue(null);
+    // Second findUnique call returns user data
+    mockFindUnique
+      .mockResolvedValueOnce({ isSuperAdmin: false } as any)
+      .mockResolvedValueOnce({ name: "Test User", email: "user@example.com" } as any);
+    mockUpsert.mockResolvedValue({} as any);
+
+    const result = await requestEmailChange({ email: "new@example.com" });
+    expect(result).toEqual({ success: true, data: { sent: true } });
+
+    // Verify upsert was called with the correct identifier pattern
+    expect(mockUpsert).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { identifier: "email-change:user-1" },
+        create: expect.objectContaining({
+          identifier: "email-change:user-1",
+        }),
+      }),
+    );
+
+    // Verify the stored value contains tokenHash and email
+    const upsertCall = mockUpsert.mock.calls[0][0];
+    const storedValue = JSON.parse(upsertCall.create.value as string);
+    expect(storedValue).toHaveProperty("tokenHash");
+    expect(storedValue).toHaveProperty("email", "new@example.com");
+
+    // Verify email was sent to the NEW address
+    const { sendMail } = await import("@/lib/email");
+    expect(sendMail).toHaveBeenCalledWith(
+      expect.objectContaining({
+        to: "new@example.com",
+        from: "noreply@test.com",
+      }),
+    );
+  });
+
+  it("uses upsert to replace any existing pending change for the user", async () => {
+    setupAuth();
+    mockFindFirst.mockResolvedValue(null);
+    mockFindUnique
+      .mockResolvedValueOnce({ isSuperAdmin: false } as any)
+      .mockResolvedValueOnce({ name: "User", email: "user@example.com" } as any);
+    mockUpsert.mockResolvedValue({} as any);
+
+    await requestEmailChange({ email: "new@example.com" });
+
+    // Upsert ensures only one pending change per user
+    const upsertCall = mockUpsert.mock.calls[0][0];
+    expect(upsertCall.where).toEqual({ identifier: "email-change:user-1" });
+    expect(upsertCall.update).toBeDefined();
+    const updateValue = JSON.parse(upsertCall.update.value as string);
+    expect(updateValue.email).toBe("new@example.com");
+  });
+});

+ 165 - 0
src/__tests__/features/auth/confirm-email-change.test.ts

@@ -0,0 +1,165 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import crypto from "crypto";
+import { NextRequest } from "next/server";
+
+// Mock db
+vi.mock("@/lib/db", () => ({
+  db: {
+    verification: {
+      findUnique: vi.fn(),
+      delete: vi.fn(),
+    },
+    user: {
+      findFirst: vi.fn(),
+      update: vi.fn(),
+    },
+    $transaction: vi.fn(),
+  },
+}));
+
+import { GET } from "@/app/api/public/confirm-email-change/route";
+import { db } from "@/lib/db";
+
+const mockFindVerification = vi.mocked(db.verification.findUnique);
+const mockDeleteVerification = vi.mocked(db.verification.delete);
+const mockFindUser = vi.mocked(db.user.findFirst);
+const mockTransaction = vi.mocked(db.$transaction);
+
+function makeRequest(params: Record<string, string> = {}) {
+  const url = new URL("http://localhost:3000/api/public/confirm-email-change");
+  for (const [key, value] of Object.entries(params)) {
+    url.searchParams.set(key, value);
+  }
+  return new NextRequest(url);
+}
+
+function makeTokenAndHash() {
+  const token = crypto.randomBytes(32).toString("hex");
+  const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
+  return { token, tokenHash };
+}
+
+function redirectUrl(response: Response): string {
+  const location = response.headers.get("location") ?? "";
+  return new URL(location).pathname + new URL(location).search;
+}
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+describe("GET /api/public/confirm-email-change", () => {
+  it("redirects to error when token is missing", async () => {
+    const res = await GET(makeRequest({ uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=invalid-token");
+  });
+
+  it("redirects to error when uid is missing", async () => {
+    const res = await GET(makeRequest({ token: "some-token" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=invalid-token");
+  });
+
+  it("redirects to error when both token and uid are missing", async () => {
+    const res = await GET(makeRequest());
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=invalid-token");
+  });
+
+  it("redirects to error when verification record not found", async () => {
+    mockFindVerification.mockResolvedValue(null);
+    const res = await GET(makeRequest({ token: "abc", uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=invalid-token");
+  });
+
+  it("redirects to token-expired when verification is expired", async () => {
+    mockFindVerification.mockResolvedValue({
+      id: "v-1",
+      identifier: "email-change:user-1",
+      value: JSON.stringify({ tokenHash: "abc", email: "new@example.com" }),
+      expiresAt: new Date(Date.now() - 1000), // expired
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    } as any);
+    mockDeleteVerification.mockResolvedValue({} as any);
+
+    const res = await GET(makeRequest({ token: "some-token", uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=token-expired");
+    expect(mockDeleteVerification).toHaveBeenCalledWith({ where: { id: "v-1" } });
+  });
+
+  it("redirects to invalid-token when stored value is not valid JSON", async () => {
+    mockFindVerification.mockResolvedValue({
+      id: "v-1",
+      identifier: "email-change:user-1",
+      value: "not-json{{{",
+      expiresAt: new Date(Date.now() + 60000),
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    } as any);
+    mockDeleteVerification.mockResolvedValue({} as any);
+
+    const res = await GET(makeRequest({ token: "some-token", uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=invalid-token");
+    expect(mockDeleteVerification).toHaveBeenCalledWith({ where: { id: "v-1" } });
+  });
+
+  it("redirects to invalid-token when token hash does not match", async () => {
+    const { tokenHash } = makeTokenAndHash();
+    mockFindVerification.mockResolvedValue({
+      id: "v-1",
+      identifier: "email-change:user-1",
+      value: JSON.stringify({ tokenHash, email: "new@example.com" }),
+      expiresAt: new Date(Date.now() + 60000),
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    } as any);
+
+    // Use a different token so hash won't match
+    const res = await GET(makeRequest({ token: "wrong-token-value-here-1234567890abcdef1234567890abcdef", uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=invalid-token");
+  });
+
+  it("redirects to email-taken when email already in use by another user", async () => {
+    const { token, tokenHash } = makeTokenAndHash();
+    mockFindVerification.mockResolvedValue({
+      id: "v-1",
+      identifier: "email-change:user-1",
+      value: JSON.stringify({ tokenHash, email: "new@example.com" }),
+      expiresAt: new Date(Date.now() + 60000),
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    } as any);
+    mockFindUser.mockResolvedValue({ id: "other-user" } as any);
+    mockDeleteVerification.mockResolvedValue({} as any);
+
+    const res = await GET(makeRequest({ token, uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toBe("/settings/account?error=email-taken");
+    expect(mockDeleteVerification).toHaveBeenCalledWith({ where: { id: "v-1" } });
+  });
+
+  it("updates user email, sets emailVerified=true, deletes verification, and redirects on success", async () => {
+    const { token, tokenHash } = makeTokenAndHash();
+    mockFindVerification.mockResolvedValue({
+      id: "v-1",
+      identifier: "email-change:user-1",
+      value: JSON.stringify({ tokenHash, email: "new@example.com" }),
+      expiresAt: new Date(Date.now() + 60000),
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    } as any);
+    mockFindUser.mockResolvedValue(null); // no other user has this email
+    mockTransaction.mockResolvedValue([{}, {}] as any);
+
+    const res = await GET(makeRequest({ token, uid: "user-1" }));
+    expect(res.status).toBe(307);
+    expect(redirectUrl(res)).toContain("emailChanged=true");
+    expect(mockTransaction).toHaveBeenCalled();
+  });
+});

+ 253 - 0
src/__tests__/features/auth/send-verification-email.test.ts

@@ -0,0 +1,253 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+/**
+ * Tests for the sendVerificationEmail callback in src/lib/auth.ts (lines 20-86).
+ * The callback logic is extracted and tested directly here.
+ */
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    systemSetting: { findUnique: vi.fn() },
+    teamInvitation: { findFirst: vi.fn() },
+    verification: { findUnique: vi.fn(), upsert: vi.fn() },
+  },
+}))
+
+vi.mock('@/lib/email', () => ({
+  sendMail: vi.fn().mockResolvedValue(undefined),
+  getFromAddress: vi.fn().mockResolvedValue('noreply@test.com'),
+}))
+
+import { db } from '@/lib/db'
+import { sendMail, getFromAddress } from '@/lib/email'
+
+const mockFindSetting = vi.mocked(db.systemSetting.findUnique)
+const mockFindInvitation = vi.mocked(db.teamInvitation.findFirst)
+const mockFindCooldown = vi.mocked(db.verification.findUnique)
+const mockUpsertCooldown = vi.mocked(db.verification.upsert)
+const mockSendMail = vi.mocked(sendMail)
+const mockGetFromAddress = vi.mocked(getFromAddress)
+
+// This is the exact logic from src/lib/auth.ts emailVerification.sendVerificationEmail
+async function sendVerificationEmailHook(params: {
+  user: { id: string; email: string; name?: string }
+  url: string
+}) {
+  const { user, url } = params
+
+  // Only send if email verification is required
+  const setting = await db.systemSetting.findUnique({
+    where: { key: 'email.verificationRequired' },
+  })
+  if (setting?.value !== 'true') return
+
+  // Skip for users with a pending invitation — acceptInvitation will set emailVerified
+  const pendingInvitation = await db.teamInvitation.findFirst({
+    where: {
+      email: user.email,
+      status: 'pending',
+    },
+  })
+  if (pendingInvitation) return
+
+  // Server-side rate limit: 60 seconds between verification emails per user
+  const cooldownKey = `email-verify-cooldown:${user.id}`
+  const existingCooldown = await db.verification.findUnique({
+    where: { identifier: cooldownKey },
+  })
+  if (existingCooldown && existingCooldown.expiresAt > new Date()) return
+
+  // Set cooldown record
+  await db.verification.upsert({
+    where: { identifier: cooldownKey },
+    create: {
+      identifier: cooldownKey,
+      value: '1',
+      expiresAt: new Date(Date.now() + 60_000),
+    },
+    update: {
+      expiresAt: new Date(Date.now() + 60_000),
+    },
+  })
+
+  try {
+    const { sendMail, getFromAddress } = await import('@/lib/email')
+    const from = await getFromAddress()
+
+    await sendMail({
+      from,
+      to: user.email,
+      subject: 'Verify your Torqvoice email',
+      html: `
+            <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+              <h2>Email Verification</h2>
+              <p>Hi${user.name ? ` ${user.name}` : ''},</p>
+              <p>Please verify your email address by clicking the button below:</p>
+              <div style="margin: 24px 0;">
+                <a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
+                  Verify Email
+                </a>
+              </div>
+              <p style="color: #6b7280; font-size: 14px;">If you didn't create an account, you can safely ignore this email.</p>
+              <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+              <p style="color: #6b7280; font-size: 12px;">
+                If the button doesn't work, copy and paste this URL into your browser:<br/>
+                <a href="${url}" style="color: #6b7280;">${url}</a>
+              </p>
+            </div>
+          `,
+    })
+  } catch (error) {
+    console.error('[emailVerification] Failed to send verification email:', error)
+  }
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  mockGetFromAddress.mockResolvedValue('noreply@test.com')
+  mockSendMail.mockResolvedValue(undefined as any)
+})
+
+const USER = { id: 'user-1', email: 'test@example.com', name: 'Test User' }
+const URL = 'https://app.test.com/verify?token=abc123'
+
+describe('sendVerificationEmail callback', () => {
+  it("does NOT send when email.verificationRequired setting is not 'true'", async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'false',
+    } as any)
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).not.toHaveBeenCalled()
+    expect(mockFindInvitation).not.toHaveBeenCalled()
+  })
+
+  it('does NOT send when email.verificationRequired is missing (null)', async () => {
+    mockFindSetting.mockResolvedValue(null)
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).not.toHaveBeenCalled()
+  })
+
+  it('does NOT send when user has a pending invitation', async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue({
+      id: 'inv-1',
+      email: USER.email,
+      status: 'pending',
+    } as any)
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).not.toHaveBeenCalled()
+  })
+
+  it('DOES send when user has an accepted invitation (only pending blocks)', async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue(null) // accepted invitations not matched by status: "pending"
+    mockFindCooldown.mockResolvedValue(null)
+    mockUpsertCooldown.mockResolvedValue({} as any)
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).toHaveBeenCalledTimes(1)
+  })
+
+  it("DOES send when user has no invitation and setting is 'true'", async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue(null)
+    mockFindCooldown.mockResolvedValue(null)
+    mockUpsertCooldown.mockResolvedValue({} as any)
+
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).toHaveBeenCalledTimes(1)
+    expect(mockSendMail).toHaveBeenCalledWith(
+      expect.objectContaining({
+        from: 'noreply@test.com',
+        to: USER.email,
+        subject: 'Verify your Torqvoice email',
+      })
+    )
+  })
+
+  it('does NOT send when cooldown record exists and has not expired', async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue(null)
+    mockFindCooldown.mockResolvedValue({
+      identifier: `email-verify-cooldown:${USER.id}`,
+      expiresAt: new Date(Date.now() + 30_000), // 30 seconds in the future
+    } as any)
+
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).not.toHaveBeenCalled()
+    expect(mockUpsertCooldown).not.toHaveBeenCalled()
+  })
+
+  it('DOES send when cooldown record exists but has expired', async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue(null)
+    mockFindCooldown.mockResolvedValue({
+      identifier: `email-verify-cooldown:${USER.id}`,
+      expiresAt: new Date(Date.now() - 5_000), // 5 seconds in the past
+    } as any)
+    mockUpsertCooldown.mockResolvedValue({} as any)
+
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockSendMail).toHaveBeenCalledTimes(1)
+  })
+
+  it('creates/upserts cooldown record when sending', async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue(null)
+    mockFindCooldown.mockResolvedValue(null)
+    mockUpsertCooldown.mockResolvedValue({} as any)
+
+    await sendVerificationEmailHook({ user: USER, url: URL })
+    expect(mockUpsertCooldown).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { identifier: `email-verify-cooldown:${USER.id}` },
+        create: expect.objectContaining({
+          identifier: `email-verify-cooldown:${USER.id}`,
+          value: '1',
+        }),
+      })
+    )
+  })
+
+  it('catches sendMail errors and logs them (does not throw)', async () => {
+    mockFindSetting.mockResolvedValue({
+      key: 'email.verificationRequired',
+      value: 'true',
+    } as any)
+    mockFindInvitation.mockResolvedValue(null)
+    mockFindCooldown.mockResolvedValue(null)
+    mockUpsertCooldown.mockResolvedValue({} as any)
+    mockSendMail.mockRejectedValue(new Error('SMTP connection failed'))
+
+    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {
+      /* no-op */
+    })
+
+    // Should NOT throw
+    await expect(sendVerificationEmailHook({ user: USER, url: URL })).resolves.toBeUndefined()
+
+    expect(consoleSpy).toHaveBeenCalledWith(
+      '[emailVerification] Failed to send verification email:',
+      expect.any(Error)
+    )
+    consoleSpy.mockRestore()
+  })
+})

+ 2 - 2
src/__tests__/multitenancy/team-invite-isolation.test.ts

@@ -31,7 +31,7 @@ vi.mock("@/lib/email", () => ({
 
 vi.mock("@/lib/db", () => ({
   db: {
-    user: { findUnique: vi.fn() },
+    user: { findUnique: vi.fn(), update: vi.fn() },
     teamInvitation: {
       findUnique: vi.fn(),
       findFirst: vi.fn(),
@@ -153,7 +153,7 @@ describe("acceptInvitation — role and org assignment correctness", () => {
       user: { id: "invited-user", email: "invited@example.com" },
     } as any);
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null);
-    vi.mocked(db.$transaction).mockResolvedValue([{}, {}] as any);
+    vi.mocked(db.$transaction).mockResolvedValue([{}, {}, {}] as any);
   });
 
   it("creates membership in the invitation's org, not any other org", async () => {

+ 12 - 0
src/app/(authenticated)/layout.tsx

@@ -11,6 +11,7 @@ import { DateSettingsProvider } from "@/components/date-settings-context";
 import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { OnlineTracker } from "@/components/online-tracker";
+import { db } from "@/lib/db";
 
 export default async function DashboardLayout({
   children,
@@ -22,6 +23,17 @@ export default async function DashboardLayout({
   if (data.status === "unauthenticated") redirect("/auth/sign-in");
   if (data.status === "no-organization") redirect("/onboarding");
 
+  // Check email verification requirement (super admins bypass this)
+  if (!data.isSuperAdmin && !data.emailVerified) {
+    const verificationSetting = await db.systemSetting.findUnique({
+      where: { key: "email.verificationRequired" },
+      select: { value: true },
+    });
+    if (verificationSetting?.value === "true") {
+      redirect("/auth/verify-email");
+    }
+  }
+
   const features = await getFeatures(data.organizationId);
   const showWhiteLabelCta = !isCloudMode() && !features.brandingRemoved;
 

+ 75 - 7
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -18,10 +18,11 @@ import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
 import { Checkbox } from '@/components/ui/checkbox'
-import { AlertTriangle, Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
+import { AlertTriangle, BadgeCheck, Check, Copy, KeyRound, Loader2, Mail, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
 import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
 import { QRCodeSVG } from 'qrcode.react'
-import { updateEmail } from '@/features/settings/Actions/accountActions'
+import { updateEmail, requestEmailChange } from '@/features/settings/Actions/accountActions'
+import { useCooldown } from '@/hooks/use-cooldown'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
 import { deleteContent } from '@/features/settings/Actions/deleteContent'
 import { signOut } from '@/lib/auth-client'
@@ -37,9 +38,13 @@ interface ContentCounts {
 
 export function AccountSettings({
   twoFactorEnabled: initialTwoFactorEnabled,
+  emailVerified: initialEmailVerified,
+  emailVerificationRequired,
   contentCounts,
 }: {
   twoFactorEnabled: boolean
+  emailVerified: boolean
+  emailVerificationRequired: boolean
   contentCounts: ContentCounts
 }) {
   const { data: session } = useSession()
@@ -53,6 +58,9 @@ export function AccountSettings({
     if (session?.user?.email) setEmail(session.user.email)
   }, [session?.user?.name, session?.user?.email])
 
+  const [emailVerified, setEmailVerified] = useState(initialEmailVerified)
+  const [sendingVerification, setSendingVerification] = useState(false)
+  const [verificationCooldown, startVerificationCooldown] = useCooldown('account-verify-email', 60)
   const [savingProfile, setSavingProfile] = useState(false)
 
   const [currentPassword, setCurrentPassword] = useState('')
@@ -217,6 +225,22 @@ export function AccountSettings({
     toast.success(t('account.twoFactorEnabledSuccess'))
   }
 
+  const handleSendVerificationEmail = async () => {
+    if (verificationCooldown > 0) return
+    setSendingVerification(true)
+    try {
+      await authClient.sendVerificationEmail({
+        email: session?.user?.email ?? '',
+        callbackURL: '/settings/account',
+      })
+      toast.success(t('account.verificationEmailSent'))
+      startVerificationCooldown()
+    } catch {
+      toast.error(t('account.verificationEmailFailed'))
+    }
+    setSendingVerification(false)
+  }
+
   const handleUpdateProfile = async () => {
     setSavingProfile(true)
     try {
@@ -226,13 +250,24 @@ export function AccountSettings({
         return
       }
       if (email !== session?.user?.email) {
-        const emailResult = await updateEmail({ email })
-        if (!emailResult.success) {
-          toast.error(emailResult.error || t('account.failedUpdateEmail'))
-          return
+        if (emailVerificationRequired) {
+          const emailResult = await requestEmailChange({ email })
+          if (!emailResult.success) {
+            toast.error(emailResult.error || t('account.failedUpdateEmail'))
+            return
+          }
+          toast.success(t('account.emailChangeSent'))
+        } else {
+          const emailResult = await updateEmail({ email })
+          if (!emailResult.success) {
+            toast.error(emailResult.error || t('account.failedUpdateEmail'))
+            return
+          }
+          toast.success(t('account.profileUpdated'))
         }
+      } else {
+        toast.success(t('account.profileUpdated'))
       }
-      toast.success(t('account.profileUpdated'))
     } catch (err) {
       toast.error(err instanceof Error ? err.message : t('account.failedUpdateProfile'))
     }
@@ -331,6 +366,39 @@ export function AccountSettings({
                 placeholder={t('account.emailPlaceholder')}
                 autoComplete="off"
               />
+              {emailVerificationRequired && (
+                <div className="flex items-center gap-2">
+                  {emailVerified ? (
+                    <span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">
+                      <BadgeCheck className="h-3 w-3" />
+                      {t('account.emailVerified')}
+                    </span>
+                  ) : (
+                    <>
+                      <span className="inline-flex items-center gap-1 rounded-full bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-400">
+                        {t('account.emailNotVerified')}
+                      </span>
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="sm"
+                        className="h-6 px-2 text-xs"
+                        onClick={handleSendVerificationEmail}
+                        disabled={sendingVerification || verificationCooldown > 0}
+                      >
+                        {sendingVerification ? (
+                          <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+                        ) : (
+                          <Mail className="mr-1 h-3 w-3" />
+                        )}
+                        {verificationCooldown > 0
+                          ? t('account.resendCooldown', { seconds: verificationCooldown })
+                          : t('account.sendVerificationEmail')}
+                      </Button>
+                    </>
+                  )}
+                </div>
+              )}
             </div>
           </div>
           <Separator />

+ 8 - 2
src/app/(authenticated)/settings/account/page.tsx

@@ -9,12 +9,16 @@ export default async function AccountSettingsPage() {
   const session = await auth.api.getSession({ headers: await headers() });
   if (!session) redirect("/auth/sign-in");
 
-  const [user, countsResult] = await Promise.all([
+  const [user, countsResult, verificationSetting] = await Promise.all([
     db.user.findUnique({
       where: { id: session.user.id },
-      select: { twoFactorEnabled: true },
+      select: { twoFactorEnabled: true, emailVerified: true },
     }),
     getContentCounts(),
+    db.systemSetting.findUnique({
+      where: { key: "email.verificationRequired" },
+      select: { value: true },
+    }),
   ]);
 
   const contentCounts = countsResult.success && countsResult.data
@@ -24,6 +28,8 @@ export default async function AccountSettingsPage() {
   return (
     <AccountSettings
       twoFactorEnabled={user?.twoFactorEnabled ?? false}
+      emailVerified={user?.emailVerified ?? false}
+      emailVerificationRequired={verificationSetting?.value === "true"}
       contentCounts={contentCounts}
     />
   );

+ 7 - 1
src/app/(public)/auth/sign-up/page.tsx

@@ -24,5 +24,11 @@ export default async function SignUpPage({
     }
   }
 
-  return <SignUpForm inviteToken={inviteToken} />
+  const verificationSetting = await db.systemSetting.findUnique({
+    where: { key: 'email.verificationRequired' },
+    select: { value: true },
+  })
+  const emailVerificationRequired = verificationSetting?.value === 'true'
+
+  return <SignUpForm inviteToken={inviteToken} emailVerificationRequired={emailVerificationRequired} />
 }

+ 5 - 1
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -11,7 +11,7 @@ import { Label } from '@/components/ui/label'
 import { Gauge, Loader2, XCircle } from 'lucide-react'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 
-export function SignUpForm({ inviteToken }: { inviteToken?: string }) {
+export function SignUpForm({ inviteToken, emailVerificationRequired }: { inviteToken?: string; emailVerificationRequired?: boolean }) {
   const t = useTranslations('auth.signUp')
   const tc = useTranslations('common')
   const [name, setName] = useState('')
@@ -42,6 +42,7 @@ export function SignUpForm({ inviteToken }: { inviteToken?: string }) {
       } else if (inviteToken) {
         const acceptResult = await acceptInvitation({ token: inviteToken })
         if (acceptResult.success) {
+          // Invited users have a known email — skip verification, go straight to dashboard
           router.push('/')
           router.refresh()
         } else {
@@ -49,6 +50,9 @@ export function SignUpForm({ inviteToken }: { inviteToken?: string }) {
           router.push('/onboarding')
           router.refresh()
         }
+      } else if (emailVerificationRequired) {
+        router.push('/auth/verify-email')
+        router.refresh()
       } else {
         router.push('/onboarding')
         router.refresh()

+ 149 - 0
src/app/(public)/auth/verify-email/page.tsx

@@ -0,0 +1,149 @@
+'use client'
+
+import { useState, useCallback, useEffect, useRef } from 'react'
+import { useTranslations } from 'next-intl'
+import { authClient, signOut, useSession } from '@/lib/auth-client'
+import { Button } from '@/components/ui/button'
+import { Gauge, Loader2, Mail, LogOut } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useCooldown } from '@/hooks/use-cooldown'
+
+const POLL_INTERVAL = 5000
+
+export default function VerifyEmailPage() {
+  const t = useTranslations('auth.verifyEmail')
+  const tc = useTranslations('common')
+  const { data: session } = useSession()
+  const [loading, setLoading] = useState(false)
+  const [sent, setSent] = useState(false)
+  const [cooldown, startCooldown] = useCooldown('verify-email', 60)
+  const router = useRouter()
+  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
+  const redirectingRef = useRef(false)
+
+  // Fetch fresh session and redirect if verified
+  const checkVerification = useCallback(async () => {
+    if (redirectingRef.current) return
+    try {
+      const { data } = await authClient.getSession({
+        query: { disableCookieCache: true },
+      })
+      if (data?.user?.emailVerified) {
+        redirectingRef.current = true
+        if (pollRef.current) clearInterval(pollRef.current)
+        router.push('/')
+        router.refresh()
+      }
+    } catch {
+      // Network error — will retry on next poll
+    }
+  }, [router])
+
+  // Check immediately on mount + poll every 5 seconds
+  useEffect(() => {
+    checkVerification()
+    pollRef.current = setInterval(checkVerification, POLL_INTERVAL)
+    return () => {
+      if (pollRef.current) clearInterval(pollRef.current)
+    }
+  }, [checkVerification])
+
+  // Also re-check when window regains focus (user may have clicked link in another tab)
+  useEffect(() => {
+    const onFocus = () => checkVerification()
+    window.addEventListener('focus', onFocus)
+    return () => window.removeEventListener('focus', onFocus)
+  }, [checkVerification])
+
+  const handleResend = useCallback(async () => {
+    if (cooldown > 0) return
+    setLoading(true)
+    try {
+      await authClient.sendVerificationEmail({
+        email: session?.user?.email ?? '',
+        callbackURL: '/',
+      })
+      setSent(true)
+      startCooldown()
+    } catch {
+      // silently handle — server-side rate limit is the real enforcement
+    } finally {
+      setLoading(false)
+    }
+  }, [cooldown, session?.user?.email, startCooldown])
+
+  const handleSignOut = async () => {
+    await signOut()
+    router.push('/auth/sign-in')
+  }
+
+  const email = session?.user?.email ?? ''
+
+  return (
+    <div className="grid-bg flex min-h-screen items-center justify-center p-4">
+      <div className="absolute inset-0 overflow-hidden">
+        <div className="absolute -top-40 -right-40 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
+        <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
+      </div>
+
+      <div className="glass relative z-10 w-full max-w-md rounded-2xl p-8 shadow-2xl">
+        <div className="mb-8 text-center">
+          <div className="mb-4 inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2">
+            <Gauge className="h-5 w-5 text-primary" />
+            <span className="gradient-text text-sm font-bold tracking-wider uppercase">
+              {tc('brandName')}
+            </span>
+          </div>
+          <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
+            <Mail className="h-8 w-8 text-primary" />
+          </div>
+          <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
+          <p className="mt-2 text-sm text-muted-foreground">
+            {t('description', { email })}
+          </p>
+        </div>
+
+        <div className="space-y-4">
+          {sent && cooldown > 0 ? (
+            <div className="space-y-2">
+              <div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-center text-sm text-emerald-700 dark:text-emerald-400">
+                {t('resent')}
+              </div>
+              <p className="text-center text-xs text-muted-foreground">
+                {t('resendCooldown', { seconds: cooldown })}
+              </p>
+            </div>
+          ) : (
+            <Button
+              onClick={handleResend}
+              className="h-11 w-full"
+              disabled={loading || cooldown > 0}
+            >
+              {loading ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Mail className="mr-2 h-4 w-4" />
+              )}
+              {cooldown > 0
+                ? t('resendCooldown', { seconds: cooldown })
+                : t('resend')}
+            </Button>
+          )}
+
+          <p className="text-center text-xs text-muted-foreground">
+            {t('checkSpam')}
+          </p>
+
+          <Button
+            variant="ghost"
+            className="h-11 w-full"
+            onClick={handleSignOut}
+          >
+            <LogOut className="mr-2 h-4 w-4" />
+            {t('signOut')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 104 - 0
src/app/api/public/confirm-email-change/route.ts

@@ -0,0 +1,104 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import crypto from "crypto";
+
+export async function GET(request: NextRequest) {
+  try {
+    const token = request.nextUrl.searchParams.get("token");
+    const uid = request.nextUrl.searchParams.get("uid");
+
+    if (!token || !uid) {
+      return NextResponse.redirect(
+        new URL("/settings/account?error=invalid-token", request.url),
+      );
+    }
+
+    // Look up the verification record by userId
+    const verification = await db.verification.findUnique({
+      where: { identifier: `email-change:${uid}` },
+    });
+
+    if (!verification) {
+      return NextResponse.redirect(
+        new URL("/settings/account?error=invalid-token", request.url),
+      );
+    }
+
+    // Check expiration first
+    if (new Date() > verification.expiresAt) {
+      await db.verification.delete({
+        where: { id: verification.id },
+      });
+      return NextResponse.redirect(
+        new URL("/settings/account?error=token-expired", request.url),
+      );
+    }
+
+    // Parse stored data
+    let stored: { tokenHash: string; email: string };
+    try {
+      stored = JSON.parse(verification.value);
+    } catch {
+      await db.verification.delete({
+        where: { id: verification.id },
+      });
+      return NextResponse.redirect(
+        new URL("/settings/account?error=invalid-token", request.url),
+      );
+    }
+
+    // Hash the provided token and compare using timing-safe comparison
+    const providedHash = crypto
+      .createHash("sha256")
+      .update(token)
+      .digest("hex");
+
+    const storedHashBuffer = Buffer.from(stored.tokenHash, "hex");
+    const providedHashBuffer = Buffer.from(providedHash, "hex");
+
+    if (
+      storedHashBuffer.length !== providedHashBuffer.length ||
+      !crypto.timingSafeEqual(storedHashBuffer, providedHashBuffer)
+    ) {
+      return NextResponse.redirect(
+        new URL("/settings/account?error=invalid-token", request.url),
+      );
+    }
+
+    // Ensure the new email isn't already taken
+    const existing = await db.user.findFirst({
+      where: { email: stored.email, NOT: { id: uid } },
+    });
+
+    if (existing) {
+      await db.verification.delete({
+        where: { id: verification.id },
+      });
+      return NextResponse.redirect(
+        new URL("/settings/account?error=email-taken", request.url),
+      );
+    }
+
+    // Update the user's email and mark as verified
+    await db.$transaction([
+      db.user.update({
+        where: { id: uid },
+        data: {
+          email: stored.email,
+          emailVerified: true,
+        },
+      }),
+      db.verification.delete({
+        where: { id: verification.id },
+      }),
+    ]);
+
+    return NextResponse.redirect(
+      new URL("/settings/account?emailChanged=true", request.url),
+    );
+  } catch {
+    return NextResponse.redirect(
+      new URL("/settings/account?error=unexpected", request.url),
+    );
+  }
+}

+ 27 - 9
src/features/admin/Actions/deleteUser.ts

@@ -1,19 +1,37 @@
-"use server";
+'use server'
 
-import { withSuperAdmin } from "@/lib/with-super-admin";
-import { db } from "@/lib/db";
-import { deleteUserSchema } from "../Schema/adminSchema";
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+import { deleteUserOrganizations } from '@/lib/delete-user-data'
+import { deleteUserSchema } from '../Schema/adminSchema'
 
 export async function deleteUser(input: { userId: string }) {
   return withSuperAdmin(async (ctx) => {
-    const { userId } = deleteUserSchema.parse(input);
+    const { userId } = deleteUserSchema.parse(input)
 
     if (userId === ctx.userId) {
-      throw new Error("Cannot delete your own account");
+      throw new Error('Cannot delete your own account')
     }
 
-    await db.user.delete({ where: { id: userId } });
+    // Get user email to clean up invitations
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: { email: true },
+    })
 
-    return { deleted: true };
-  });
+    // Handle all org cleanup (delete empty orgs, reassign data in shared orgs)
+    await deleteUserOrganizations(userId)
+
+    // Delete the user — cascades sessions, accounts, 2FA
+    await db.user.delete({ where: { id: userId } })
+
+    // Clean up invitations for this email
+    if (user?.email) {
+      await db.teamInvitation.deleteMany({
+        where: { email: user.email },
+      })
+    }
+
+    return { deleted: true }
+  })
 }

+ 2 - 0
src/features/admin/Actions/getUsers.ts

@@ -27,6 +27,7 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
           isSuperAdmin: true,
           createdAt: true,
           lastSeen: true,
+          emailVerified: true,
         },
         orderBy: { createdAt: "desc" },
         skip: (page - 1) * pageSize,
@@ -52,6 +53,7 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
         isSuperAdmin: u.isSuperAdmin,
         createdAt: u.createdAt.toISOString(),
         lastSeen: u.lastSeen?.toISOString() ?? null,
+        emailVerified: u.emailVerified,
         organizationCount: countMap.get(u.id) ?? 0,
       })),
       total,

+ 17 - 0
src/features/admin/Components/admin-settings.tsx

@@ -32,6 +32,9 @@ export function AdminSettings({
   const [registrationDisabled, setRegistrationDisabled] = useState(
     initial[SYSTEM_SETTING_KEYS.REGISTRATION_DISABLED] === 'true'
   )
+  const [emailVerificationRequired, setEmailVerificationRequired] = useState(
+    initial[SYSTEM_SETTING_KEYS.EMAIL_VERIFICATION_REQUIRED] === 'true'
+  )
 
   // Email provider
   const [emailProvider, setEmailProvider] = useState<EmailProviderType>(
@@ -128,6 +131,7 @@ export function AdminSettings({
     startTransition(async () => {
       const data: Record<string, string> = {
         [SYSTEM_SETTING_KEYS.REGISTRATION_DISABLED]: String(registrationDisabled),
+        [SYSTEM_SETTING_KEYS.EMAIL_VERIFICATION_REQUIRED]: String(emailVerificationRequired),
         [SYSTEM_SETTING_KEYS.EMAIL_PROVIDER]: emailProvider,
         // SMTP
         [SYSTEM_SETTING_KEYS.SMTP_HOST]: smtpHost,
@@ -220,6 +224,19 @@ export function AdminSettings({
               onCheckedChange={setRegistrationDisabled}
             />
           </div>
+          <div className="flex items-center justify-between">
+            <div className="space-y-0.5">
+              <Label htmlFor="email-verification-toggle">{t('adminSettings.requireEmailVerification')}</Label>
+              <p className="text-xs text-muted-foreground">
+                {t('adminSettings.requireEmailVerificationHint')}
+              </p>
+            </div>
+            <Switch
+              id="email-verification-toggle"
+              checked={emailVerificationRequired}
+              onCheckedChange={setEmailVerificationRequired}
+            />
+          </div>
         </CardContent>
       </Card>
 

+ 14 - 4
src/features/admin/Components/admin-users.tsx

@@ -23,7 +23,8 @@ import {
   DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import { DataTablePagination } from "@/components/data-table-pagination";
-import { Loader2, MoreHorizontal, Search, ShieldCheck, ShieldOff, Trash2 } from "lucide-react";
+import { BadgeCheck, Loader2, MoreHorizontal, Search, ShieldCheck, ShieldOff, Trash2 } from "lucide-react";
+import { useFormatDate } from "@/lib/use-format-date";
 import { toggleSuperAdmin } from "../Actions/toggleSuperAdmin";
 import { deleteUser } from "../Actions/deleteUser";
 
@@ -32,6 +33,7 @@ type UserRow = {
   name: string;
   email: string;
   isSuperAdmin: boolean;
+  emailVerified: boolean;
   createdAt: string;
   lastSeen: string | null;
   organizationCount: number;
@@ -69,6 +71,7 @@ export function AdminUsers({
   const pathname = usePathname();
   const searchParams = useSearchParams();
   const confirm = useConfirm();
+  const { formatDate, formatDateTime } = useFormatDate();
   const [isPending, startTransition] = useTransition();
   const [searchInput, setSearchInput] = useState(search);
 
@@ -186,7 +189,14 @@ export function AdminUsers({
               data.users.map((user) => (
                 <TableRow key={user.id}>
                   <TableCell className="font-medium">{user.name}</TableCell>
-                  <TableCell>{user.email}</TableCell>
+                  <TableCell>
+                    <span className="flex items-center gap-1.5">
+                      {user.email}
+                      {user.emailVerified && (
+                        <span title={t("users.emailVerified")}><BadgeCheck className="h-4 w-4 text-blue-500" /></span>
+                      )}
+                    </span>
+                  </TableCell>
                   <TableCell>
                     {user.isSuperAdmin ? (
                       <Badge variant="default" className="gap-1">
@@ -199,7 +209,7 @@ export function AdminUsers({
                   </TableCell>
                   <TableCell className="text-center">{user.organizationCount}</TableCell>
                   <TableCell>
-                    {new Date(user.createdAt).toLocaleDateString()}
+                    {formatDate(user.createdAt)}
                   </TableCell>
                   <TableCell>
                     {user.lastSeen && Date.now() - new Date(user.lastSeen).getTime() < 5 * 60 * 1000 ? (
@@ -209,7 +219,7 @@ export function AdminUsers({
                       </span>
                     ) : (
                       <span className="text-sm text-muted-foreground">
-                        {user.lastSeen ? new Date(user.lastSeen).toLocaleString() : "—"}
+                        {user.lastSeen ? formatDateTime(user.lastSeen) : "—"}
                       </span>
                     )}
                   </TableCell>

+ 3 - 0
src/features/admin/Schema/systemSettingsSchema.ts

@@ -4,6 +4,9 @@ export const SYSTEM_SETTING_KEYS = {
   // Registration
   REGISTRATION_DISABLED: "registration.disabled",
 
+  // Email Verification
+  EMAIL_VERIFICATION_REQUIRED: "email.verificationRequired",
+
   // Email Provider
   EMAIL_PROVIDER: "email.provider",
 

+ 89 - 1
src/features/settings/Actions/accountActions.ts

@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { z } from "zod";
+import crypto from "crypto";
 
 const updateEmailSchema = z.object({
   email: z.string().email("Invalid email address"),
@@ -23,9 +24,96 @@ export async function updateEmail(data: { email: string }) {
 
     await db.user.update({
       where: { id: userId },
-      data: { email: parsed.email },
+      data: { email: parsed.email, emailVerified: false },
     });
 
     return { email: parsed.email };
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS }] });
 }
+
+/**
+ * Request email change with verification.
+ * Instead of changing the email immediately, sends a confirmation link
+ * to the NEW email address. The user's current email stays unchanged
+ * until they click the confirmation link.
+ *
+ * Security:
+ * - Random opaque token in URL (no user data leaked)
+ * - Token hash stored in DB (DB compromise doesn't expose valid tokens)
+ * - Timing-safe comparison on verification
+ * - Upsert by userId ensures only one pending change per user
+ */
+export async function requestEmailChange(data: { email: string }) {
+  return withAuth(async ({ userId }) => {
+    const parsed = updateEmailSchema.parse(data);
+
+    const existing = await db.user.findFirst({
+      where: { email: parsed.email, NOT: { id: userId } },
+    });
+
+    if (existing) {
+      throw new Error("Email is already in use");
+    }
+
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: { name: true, email: true },
+    });
+
+    if (!user) throw new Error("User not found");
+
+    // Generate a cryptographically random token
+    const token = crypto.randomBytes(32).toString("hex");
+
+    // Store hash of token (so DB leak doesn't expose valid tokens)
+    const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
+
+    // Store: identifier for upsert (one pending change per user), value contains hash + email
+    const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
+    await db.verification.upsert({
+      where: { identifier: `email-change:${userId}` },
+      create: {
+        identifier: `email-change:${userId}`,
+        value: JSON.stringify({ tokenHash, email: parsed.email }),
+        expiresAt,
+      },
+      update: {
+        value: JSON.stringify({ tokenHash, email: parsed.email }),
+        expiresAt,
+      },
+    });
+
+    // URL contains only the opaque token and userId (no email leaked)
+    const baseURL = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
+    const confirmUrl = `${baseURL}/api/public/confirm-email-change?token=${token}&uid=${userId}`;
+
+    const { sendMail, getFromAddress } = await import("@/lib/email");
+    const from = await getFromAddress();
+
+    await sendMail({
+      from,
+      to: parsed.email,
+      subject: "Confirm your new Torqvoice email",
+      html: `
+        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+          <h2>Email Change Confirmation</h2>
+          <p>Hi${user.name ? ` ${user.name}` : ""},</p>
+          <p>You requested to change your Torqvoice email to this address. Click the button below to confirm:</p>
+          <div style="margin: 24px 0;">
+            <a href="${confirmUrl}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
+              Confirm Email Change
+            </a>
+          </div>
+          <p style="color: #6b7280; font-size: 14px;">If you didn't request this change, you can safely ignore this email.</p>
+          <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+          <p style="color: #6b7280; font-size: 12px;">
+            This link will expire in 24 hours. If it doesn't work, copy and paste this URL into your browser:<br/>
+            <a href="${confirmUrl}" style="color: #6b7280;">${confirmUrl}</a>
+          </p>
+        </div>
+      `,
+    });
+
+    return { sent: true };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS }] });
+}

+ 26 - 147
src/features/settings/Actions/deleteAccount.ts

@@ -1,153 +1,32 @@
-"use server";
+'use server'
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { resolveUploadPath } from "@/lib/resolve-upload-path";
-import { unlink, rm } from "fs/promises";
-import path from "path";
-import Stripe from "stripe";
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { deleteUserOrganizations } from '@/lib/delete-user-data'
 
 export async function deleteAccount() {
-  return withAuth(async ({ userId, organizationId }) => {
-    // Count other members in the organization
-    const memberCount = organizationId
-      ? await db.organizationMember.count({
-          where: { organizationId },
-        })
-      : 0;
-
-    const isLastMember = memberCount <= 1;
-
-    if (isLastMember && organizationId) {
-      // --- LAST MEMBER: delete everything ---
-
-      // Collect file paths to clean up from disk
-      const filePaths: string[] = [];
-
-      const attachments = await db.serviceAttachment.findMany({
-        where: { serviceRecord: { vehicle: { organizationId } } },
-        select: { fileUrl: true },
-      });
-      for (const att of attachments) {
-        filePaths.push(resolveUploadPath(att.fileUrl));
-      }
-
-      const inventoryParts = await db.inventoryPart.findMany({
-        where: { organizationId },
-        select: { imageUrl: true },
-      });
-      for (const part of inventoryParts) {
-        if (part.imageUrl) filePaths.push(resolveUploadPath(part.imageUrl));
-      }
-
-      const vehicles = await db.vehicle.findMany({
-        where: { organizationId },
-        select: { imageUrl: true },
-      });
-      for (const v of vehicles) {
-        if (v.imageUrl) filePaths.push(resolveUploadPath(v.imageUrl));
-      }
-
-      // Cancel Stripe subscription before deleting org data
-      const subscription = await db.subscription.findUnique({
-        where: { organizationId },
-        select: { stripeSubscriptionId: true },
-      });
-
-      if (subscription?.stripeSubscriptionId && process.env.STRIPE_SECRET_KEY) {
-        try {
-          const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
-          await stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
-        } catch {
-          // Subscription may already be canceled on Stripe's side
-        }
-      }
-
-      // Delete membership first (not auto-cascaded from user deletion)
-      await db.organizationMember.deleteMany({
-        where: { userId },
-      });
-
-      // Delete the organization — cascades all org data (vehicles, customers,
-      // quotes, inventory, custom fields, settings, roles, invitations, subscription)
-      await db.organization.delete({
-        where: { id: organizationId },
-      });
-
-      // Delete the user — cascades sessions, accounts, 2FA
-      await db.user.delete({
-        where: { id: userId },
-      });
-
-      // Clean up files from disk (best effort)
-      for (const filePath of filePaths) {
-        try {
-          await unlink(filePath);
-        } catch {
-          // File may already be missing
-        }
-      }
-
-      // Try to remove the org upload directory
-      try {
-        const orgUploadDir = path.join(process.cwd(), "data", "uploads", organizationId);
-        await rm(orgUploadDir, { recursive: true, force: true });
-      } catch {
-        // Directory may not exist
-      }
-    } else {
-      // --- NOT LAST MEMBER: reassign org data, then delete user ---
-
-      // Find another member to reassign data to
-      const otherMember = await db.organizationMember.findFirst({
-        where: { organizationId, NOT: { userId } },
-        select: { userId: true },
-      });
-
-      if (otherMember) {
-        const newOwnerId = otherMember.userId;
-
-        // Reassign all org data owned by this user to another member
-        await db.$transaction([
-          db.vehicle.updateMany({
-            where: { userId, organizationId },
-            data: { userId: newOwnerId },
-          }),
-          db.customer.updateMany({
-            where: { userId, organizationId },
-            data: { userId: newOwnerId },
-          }),
-          db.quote.updateMany({
-            where: { userId, organizationId },
-            data: { userId: newOwnerId },
-          }),
-          db.inventoryPart.updateMany({
-            where: { userId, organizationId },
-            data: { userId: newOwnerId },
-          }),
-          db.customFieldDefinition.updateMany({
-            where: { userId, organizationId },
-            data: { userId: newOwnerId },
-          }),
-          db.appSetting.updateMany({
-            where: { userId, organizationId },
-            data: { userId: newOwnerId },
-          }),
-        ]);
-      }
-
-      // Remove membership
-      await db.organizationMember.deleteMany({
-        where: { userId },
-      });
-
-      // Delete the user — cascades only user-specific data
-      // (sessions, accounts, 2FA; org data was reassigned above)
-      await db.user.delete({
-        where: { id: userId },
-      });
+  return withAuth(async ({ userId }) => {
+    // Get user email to clean up invitations
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: { email: true },
+    })
+
+    // Handle all org cleanup (delete empty orgs, reassign data in shared orgs)
+    await deleteUserOrganizations(userId)
+
+    // Delete the user — cascades sessions, accounts, 2FA
+    await db.user.delete({
+      where: { id: userId },
+    })
+
+    // Clean up invitations for this email
+    if (user?.email) {
+      await db.teamInvitation.deleteMany({
+        where: { email: user.email },
+      })
     }
 
-    return { deleted: true };
-  });
+    return { deleted: true }
+  })
 }

+ 5 - 1
src/features/team/Actions/acceptInvitation.ts

@@ -47,7 +47,7 @@ export async function acceptInvitation(input: unknown) {
       return { success: true, data: { accepted: true } };
     }
 
-    // Create membership and mark invitation as accepted
+    // Create membership, mark invitation as accepted, and verify email
     await db.$transaction([
       db.organizationMember.create({
         data: {
@@ -61,6 +61,10 @@ export async function acceptInvitation(input: unknown) {
         where: { id: invitation.id },
         data: { status: "accepted" },
       }),
+      db.user.update({
+        where: { id: session.user.id },
+        data: { emailVerified: true },
+      }),
     ]);
 
     // Set the active org cookie

+ 49 - 0
src/hooks/use-cooldown.ts

@@ -0,0 +1,49 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+
+/**
+ * A cooldown timer that persists across page refreshes via localStorage.
+ * Returns [secondsRemaining, startCooldown].
+ *
+ * Initializes to 0 on the server to avoid hydration mismatches,
+ * then reads the persisted expiry from localStorage after mount.
+ */
+export function useCooldown(
+  key: string,
+  durationSeconds: number,
+): [number, () => void] {
+  const storageKey = `cooldown:${key}`;
+  const [remaining, setRemaining] = useState(0);
+  const mountedRef = useRef(false);
+
+  const getRemaining = useCallback(() => {
+    const expiresAt = localStorage.getItem(storageKey);
+    if (!expiresAt) return 0;
+    const r = Math.ceil((Number(expiresAt) - Date.now()) / 1000);
+    return r > 0 ? r : 0;
+  }, [storageKey]);
+
+  // Read persisted cooldown after mount
+  useEffect(() => {
+    mountedRef.current = true;
+    setRemaining(getRemaining());
+  }, [getRemaining]);
+
+  // Tick down every second
+  useEffect(() => {
+    if (remaining <= 0) return;
+    const timer = setInterval(() => {
+      const r = getRemaining();
+      setRemaining(r);
+      if (r <= 0) clearInterval(timer);
+    }, 1000);
+    return () => clearInterval(timer);
+  }, [remaining, getRemaining]);
+
+  const start = useCallback(() => {
+    const expiresAt = Date.now() + durationSeconds * 1000;
+    localStorage.setItem(storageKey, String(expiresAt));
+    setRemaining(durationSeconds);
+  }, [storageKey, durationSeconds]);
+
+  return [remaining, start];
+}

+ 101 - 31
src/lib/auth.ts

@@ -1,34 +1,104 @@
-import { passkey } from "@better-auth/passkey";
-import { betterAuth } from "better-auth";
-import { prismaAdapter } from "better-auth/adapters/prisma";
-import { nextCookies } from "better-auth/next-js";
-import { twoFactor } from "better-auth/plugins/two-factor";
-import { db } from "./db";
+import { passkey } from '@better-auth/passkey'
+import { betterAuth } from 'better-auth'
+import { prismaAdapter } from 'better-auth/adapters/prisma'
+import { nextCookies } from 'better-auth/next-js'
+import { twoFactor } from 'better-auth/plugins/two-factor'
+import { db } from './db'
 
-const baseURL = process.env.NEXT_PUBLIC_APP_URL;
-const isProduction = baseURL?.startsWith("https://");
+const baseURL = process.env.NEXT_PUBLIC_APP_URL
+const isProduction = baseURL?.startsWith('https://')
 
 export const auth = betterAuth({
   baseURL,
-  basePath: "/api/public/auth",
+  basePath: '/api/public/auth',
   trustedOrigins: baseURL ? [baseURL] : [],
   database: prismaAdapter(db, {
-    provider: "postgresql",
+    provider: 'postgresql',
   }),
+  emailVerification: {
+    sendOnSignUp: true,
+    sendVerificationEmail: async ({ user, url }) => {
+      // Only send if email verification is required
+      const setting = await db.systemSetting.findUnique({
+        where: { key: 'email.verificationRequired' },
+      })
+      if (setting?.value !== 'true') return
+
+      // Skip for users with a pending invitation — acceptInvitation will set emailVerified
+      const pendingInvitation = await db.teamInvitation.findFirst({
+        where: {
+          email: user.email,
+          status: 'pending',
+        },
+      })
+      if (pendingInvitation) return
+
+      // Server-side rate limit: 60 seconds between verification emails per user
+      const cooldownKey = `email-verify-cooldown:${user.id}`
+      const existingCooldown = await db.verification.findUnique({
+        where: { identifier: cooldownKey },
+      })
+      if (existingCooldown && existingCooldown.expiresAt > new Date()) return
+
+      // Set cooldown record
+      await db.verification.upsert({
+        where: { identifier: cooldownKey },
+        create: {
+          identifier: cooldownKey,
+          value: '1',
+          expiresAt: new Date(Date.now() + 60_000),
+        },
+        update: {
+          expiresAt: new Date(Date.now() + 60_000),
+        },
+      })
+
+      try {
+        const { sendMail, getFromAddress } = await import('@/lib/email')
+        const from = await getFromAddress()
+
+        await sendMail({
+          from,
+          to: user.email,
+          subject: 'Verify your Torqvoice email',
+          html: `
+            <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+              <h2>Email Verification</h2>
+              <p>Hi${user.name ? ` ${user.name}` : ''},</p>
+              <p>Please verify your email address by clicking the button below:</p>
+              <div style="margin: 24px 0;">
+                <a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
+                  Verify Email
+                </a>
+              </div>
+              <p style="color: #6b7280; font-size: 14px;">If you didn't create an account, you can safely ignore this email.</p>
+              <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+              <p style="color: #6b7280; font-size: 12px;">
+                If the button doesn't work, copy and paste this URL into your browser:<br/>
+                <a href="${url}" style="color: #6b7280;">${url}</a>
+              </p>
+            </div>
+          `,
+        })
+      } catch (error) {
+        console.error('[emailVerification] Failed to send verification email:', error)
+      }
+    },
+  },
   emailAndPassword: {
     enabled: true,
     sendResetPassword: async ({ user, url }) => {
-      const { sendMail, getFromAddress } = await import("@/lib/email");
-      const from = await getFromAddress();
+      const { sendMail, getFromAddress } = await import('@/lib/email')
+      const from = await getFromAddress()
 
       await sendMail({
         from,
         to: user.email,
-        subject: "Reset your Torqvoice password",
+        subject: 'Reset your Torqvoice password',
         html: `
           <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
             <h2>Password Reset</h2>
-            <p>Hi${user.name ? ` ${user.name}` : ""},</p>
+            <p>Hi${user.name ? ` ${user.name}` : ''},</p>
             <p>We received a request to reset your password. Click the button below to set a new password:</p>
             <div style="margin: 24px 0;">
               <a href="${url}" style="display: inline-block; padding: 12px 24px; background-color: #171717; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
@@ -43,7 +113,7 @@ export const auth = betterAuth({
             </p>
           </div>
         `,
-      });
+      })
     },
   },
   session: {
@@ -64,7 +134,7 @@ export const auth = betterAuth({
           await db.user.update({
             where: { id: session.userId },
             data: { lastLogin: new Date() },
-          });
+          })
         },
       },
     },
@@ -73,48 +143,48 @@ export const auth = betterAuth({
         before: async (user) => {
           // Block registration if disabled via system settings
           const setting = await db.systemSetting.findUnique({
-            where: { key: "registration.disabled" },
-          });
-          if (setting?.value === "true") {
+            where: { key: 'registration.disabled' },
+          })
+          if (setting?.value === 'true') {
             // Allow registration if there's a pending invitation for this email
             const invitation = await db.teamInvitation.findFirst({
               where: {
                 email: user.email,
-                status: "pending",
+                status: 'pending',
                 expiresAt: { gt: new Date() },
               },
-            });
+            })
             if (!invitation) {
-              return false;
+              return false
             }
           }
-          return { data: user };
+          return { data: user }
         },
         after: async (user) => {
           // Auto-promote the first registered user to super admin
-          const count = await db.user.count();
+          const count = await db.user.count()
           if (count === 1) {
             await db.user.update({
               where: { id: user.id },
               data: { isSuperAdmin: true, termsAcceptedAt: new Date() },
-            });
+            })
           } else {
             await db.user.update({
               where: { id: user.id },
               data: { termsAcceptedAt: new Date() },
-            });
+            })
           }
         },
       },
     },
   },
   plugins: [
-    twoFactor({ issuer: "Torqvoice" }),
+    twoFactor({ issuer: 'Torqvoice' }),
     passkey({
-      rpID: baseURL ? new URL(baseURL).hostname : "localhost",
-      rpName: "Torqvoice",
-      origin: baseURL || "http://localhost:3000",
+      rpID: baseURL ? new URL(baseURL).hostname : 'localhost',
+      rpName: 'Torqvoice',
+      origin: baseURL || 'http://localhost:3000',
     }),
     nextCookies(), // Must be last plugin
   ],
-});
+})

+ 151 - 0
src/lib/delete-user-data.ts

@@ -0,0 +1,151 @@
+import { db } from '@/lib/db'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { unlink, rm } from 'fs/promises'
+import path from 'path'
+import Stripe from 'stripe'
+
+/**
+ * Clean up a single organization where the user is the last member.
+ * Cancels Stripe subscription, deletes org (cascades all data), and removes upload files.
+ */
+async function deleteOrganization(organizationId: string, userId: string) {
+  // Collect file paths to clean up from disk
+  const filePaths: string[] = []
+
+  const attachments = await db.serviceAttachment.findMany({
+    where: { serviceRecord: { vehicle: { organizationId } } },
+    select: { fileUrl: true },
+  })
+  for (const att of attachments) {
+    filePaths.push(resolveUploadPath(att.fileUrl))
+  }
+
+  const inventoryParts = await db.inventoryPart.findMany({
+    where: { organizationId },
+    select: { imageUrl: true },
+  })
+  for (const part of inventoryParts) {
+    if (part.imageUrl) filePaths.push(resolveUploadPath(part.imageUrl))
+  }
+
+  const vehicles = await db.vehicle.findMany({
+    where: { organizationId },
+    select: { imageUrl: true },
+  })
+  for (const v of vehicles) {
+    if (v.imageUrl) filePaths.push(resolveUploadPath(v.imageUrl))
+  }
+
+  // Cancel Stripe subscription before deleting org data
+  const subscription = await db.subscription.findUnique({
+    where: { organizationId },
+    select: { stripeSubscriptionId: true },
+  })
+
+  if (subscription?.stripeSubscriptionId && process.env.STRIPE_SECRET_KEY) {
+    try {
+      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
+      await stripe.subscriptions.cancel(subscription.stripeSubscriptionId)
+    } catch {
+      // Subscription may already be canceled on Stripe's side
+    }
+  }
+
+  // Delete membership first (not auto-cascaded from user deletion)
+  await db.organizationMember.deleteMany({
+    where: { userId },
+  })
+
+  // Delete the organization — cascades all org data (vehicles, customers,
+  // quotes, inventory, custom fields, settings, roles, invitations, subscription)
+  await db.organization.delete({
+    where: { id: organizationId },
+  })
+
+  // Clean up files from disk (best effort)
+  for (const filePath of filePaths) {
+    try {
+      await unlink(filePath)
+    } catch {
+      // File may already be missing
+    }
+  }
+
+  // Try to remove the org upload directory
+  try {
+    const orgUploadDir = path.join(process.cwd(), 'data', 'uploads', organizationId)
+    await rm(orgUploadDir, { recursive: true, force: true })
+  } catch {
+    // Directory may not exist
+  }
+}
+
+/**
+ * Reassign org data from one user to another member, then remove membership.
+ */
+async function reassignOrgData(organizationId: string, userId: string) {
+  const otherMember = await db.organizationMember.findFirst({
+    where: { organizationId, NOT: { userId } },
+    select: { userId: true },
+  })
+
+  if (otherMember) {
+    const newOwnerId = otherMember.userId
+
+    await db.$transaction([
+      db.vehicle.updateMany({
+        where: { userId, organizationId },
+        data: { userId: newOwnerId },
+      }),
+      db.customer.updateMany({
+        where: { userId, organizationId },
+        data: { userId: newOwnerId },
+      }),
+      db.quote.updateMany({
+        where: { userId, organizationId },
+        data: { userId: newOwnerId },
+      }),
+      db.inventoryPart.updateMany({
+        where: { userId, organizationId },
+        data: { userId: newOwnerId },
+      }),
+      db.customFieldDefinition.updateMany({
+        where: { userId, organizationId },
+        data: { userId: newOwnerId },
+      }),
+      db.appSetting.updateMany({
+        where: { userId, organizationId },
+        data: { userId: newOwnerId },
+      }),
+    ])
+  }
+
+  await db.organizationMember.deleteMany({
+    where: { userId, organizationId },
+  })
+}
+
+/**
+ * Handle all organization cleanup for a user being deleted.
+ * For each org the user belongs to:
+ *  - If last member: delete the entire org and its data
+ *  - If not last member: reassign data to another member
+ */
+export async function deleteUserOrganizations(userId: string) {
+  const memberships = await db.organizationMember.findMany({
+    where: { userId },
+    select: { organizationId: true },
+  })
+
+  for (const { organizationId } of memberships) {
+    const memberCount = await db.organizationMember.count({
+      where: { organizationId },
+    })
+
+    if (memberCount <= 1) {
+      await deleteOrganization(organizationId, userId)
+    } else {
+      await reassignOrgData(organizationId, userId)
+    }
+  }
+}

+ 7 - 2
src/lib/get-layout-data.ts

@@ -11,6 +11,7 @@ type AuthResult =
       organizationId: string;
       role: string;
       isSuperAdmin: boolean;
+      emailVerified: boolean;
       companyLogo: string | undefined;
       dateFormat: string | undefined;
       timeFormat: string | undefined;
@@ -26,11 +27,14 @@ export async function getLayoutData(): Promise<AuthResult> {
     getCachedMembership(session.user.id),
     db.user.findUnique({
       where: { id: session.user.id },
-      select: { isSuperAdmin: true },
+      select: { isSuperAdmin: true, emailVerified: true },
     }),
   ]);
 
-  const isSuperAdmin = user?.isSuperAdmin ?? false;
+  // User record deleted (e.g. admin removed the account) but session cookie still cached
+  if (!user) return { status: "unauthenticated" };
+
+  const isSuperAdmin = user.isSuperAdmin;
 
   if (!membership && !isSuperAdmin) return { status: "no-organization" };
 
@@ -68,6 +72,7 @@ export async function getLayoutData(): Promise<AuthResult> {
     organizationId: membership?.organizationId ?? "",
     role: isSuperAdmin ? "super_admin" : (membership?.role ?? "member"),
     isSuperAdmin,
+    emailVerified: user?.emailVerified ?? false,
     companyLogo: orgMap.get(SETTING_KEYS.COMPANY_LOGO) || undefined,
     dateFormat: orgMap.get(SETTING_KEYS.DATE_FORMAT) || undefined,
     timeFormat: orgMap.get(SETTING_KEYS.TIME_FORMAT) || undefined,