Quellcode durchsuchen

Add Passkey (WebAuthn) authentication (#21)

* update qoutes when shared

* added passkey

* tests
Bernt Christian Egeland vor 7 Monaten
Ursprung
Commit
7c495a837d

+ 5 - 1
messages/de/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Noch kein Konto?",
     "noAccount": "Noch kein Konto?",
     "createOne": "Jetzt erstellen",
     "createOne": "Jetzt erstellen",
     "termsAgreement": "Durch die Nutzung dieses Dienstes stimmen Sie unseren",
     "termsAgreement": "Durch die Nutzung dieses Dienstes stimmen Sie unseren",
+    "or": "oder",
+    "passkey": "Mit Passkey anmelden",
+    "passkeyDescription": "Verwenden Sie Ihren Fingerabdruck, Ihr Gesicht oder Ihren Sicherheitsschlüssel",
     "errors": {
     "errors": {
       "tooManyAttempts": "Zu viele Versuche, bitte versuchen Sie es später erneut.",
       "tooManyAttempts": "Zu viele Versuche, bitte versuchen Sie es später erneut.",
-      "invalidCredentials": "Ungültige E-Mail-Adresse oder ungültiges Passwort"
+      "invalidCredentials": "Ungültige E-Mail-Adresse oder ungültiges Passwort",
+      "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/de/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Geben Sie <bold>delete my data</bold> zur Bestatigung ein",
     "deleteContentConfirmPrompt": "Geben Sie <bold>delete my data</bold> zur Bestatigung ein",
     "deleteSelected": "{count} ausgewahlte loschen",
     "deleteSelected": "{count} ausgewahlte loschen",
     "contentDeleted": "Ausgewahlte Inhalte wurden dauerhaft geloscht",
     "contentDeleted": "Ausgewahlte Inhalte wurden dauerhaft geloscht",
-    "failedDeleteContent": "Inhalte konnten nicht geloscht werden"
+    "failedDeleteContent": "Inhalte konnten nicht geloscht werden",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Melden Sie sich mit Biometrie oder einem Sicherheitsschlüssel anstelle eines Passworts an.",
+      "noPasskeys": "Noch keine Passkeys registriert.",
+      "unnamed": "Unbenannter Passkey",
+      "singleDevice": "Nur dieses Gerät",
+      "multiDevice": "Geräteübergreifend synchronisiert",
+      "register": "Passkey registrieren",
+      "registering": "Registriere...",
+      "registerSuccess": "Passkey erfolgreich registriert",
+      "registerFailed": "Passkey konnte nicht registriert werden",
+      "delete": "Löschen",
+      "deleteSuccess": "Passkey gelöscht",
+      "deleteFailed": "Passkey konnte nicht gelöscht werden",
+      "rename": "Passkey umbenennen",
+      "renameFailed": "Passkey konnte nicht umbenannt werden",
+      "nameLabel": "Name",
+      "namePlaceholder": "z.B. MacBook Pro, iPhone",
+      "cancel": "Abbrechen",
+      "save": "Speichern",
+      "confirmDeleteTitle": "Passkey löschen",
+      "confirmDelete": "Möchten Sie diesen Passkey wirklich löschen? Sie können sich damit nicht mehr anmelden."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Team",
     "title": "Team",

+ 5 - 1
messages/en/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Don't have an account?",
     "noAccount": "Don't have an account?",
     "createOne": "Create one",
     "createOne": "Create one",
     "termsAgreement": "By using this service you agree to our",
     "termsAgreement": "By using this service you agree to our",
+    "or": "or",
+    "passkey": "Sign in with Passkey",
+    "passkeyDescription": "Use your fingerprint, face, or security key",
     "errors": {
     "errors": {
       "tooManyAttempts": "Too many attempts, try again later.",
       "tooManyAttempts": "Too many attempts, try again later.",
-      "invalidCredentials": "Invalid email or password"
+      "invalidCredentials": "Invalid email or password",
+      "passkeyFailed": "Passkey sign-in failed. Try again or use email and password."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/en/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Type <bold>delete my data</bold> to confirm",
     "deleteContentConfirmPrompt": "Type <bold>delete my data</bold> to confirm",
     "deleteSelected": "Delete {count} selected",
     "deleteSelected": "Delete {count} selected",
     "contentDeleted": "Selected content has been permanently deleted",
     "contentDeleted": "Selected content has been permanently deleted",
-    "failedDeleteContent": "Failed to delete content"
+    "failedDeleteContent": "Failed to delete content",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Sign in with biometrics or a security key instead of a password.",
+      "noPasskeys": "No passkeys registered yet.",
+      "unnamed": "Unnamed passkey",
+      "singleDevice": "This device only",
+      "multiDevice": "Synced across devices",
+      "register": "Register Passkey",
+      "registering": "Registering...",
+      "registerSuccess": "Passkey registered successfully",
+      "registerFailed": "Failed to register passkey",
+      "delete": "Delete",
+      "deleteSuccess": "Passkey deleted",
+      "deleteFailed": "Failed to delete passkey",
+      "rename": "Rename Passkey",
+      "renameFailed": "Failed to rename passkey",
+      "nameLabel": "Name",
+      "namePlaceholder": "e.g. MacBook Pro, iPhone",
+      "cancel": "Cancel",
+      "save": "Save",
+      "confirmDeleteTitle": "Delete Passkey",
+      "confirmDelete": "Are you sure you want to delete this passkey? You won't be able to sign in with it anymore."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Team",
     "title": "Team",

+ 5 - 1
messages/es/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "No tiene una cuenta?",
     "noAccount": "No tiene una cuenta?",
     "createOne": "Crear una",
     "createOne": "Crear una",
     "termsAgreement": "Al utilizar este servicio, usted acepta nuestros",
     "termsAgreement": "Al utilizar este servicio, usted acepta nuestros",
+    "or": "o",
+    "passkey": "Iniciar sesión con Passkey",
+    "passkeyDescription": "Usa tu huella digital, rostro o llave de seguridad",
     "errors": {
     "errors": {
       "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo mas tarde.",
       "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo mas tarde.",
-      "invalidCredentials": "Correo electronico o contrasena no validos"
+      "invalidCredentials": "Correo electronico o contrasena no validos",
+      "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/es/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Escriba <bold>delete my data</bold> para confirmar",
     "deleteContentConfirmPrompt": "Escriba <bold>delete my data</bold> para confirmar",
     "deleteSelected": "Eliminar {count} seleccionados",
     "deleteSelected": "Eliminar {count} seleccionados",
     "contentDeleted": "El contenido seleccionado ha sido eliminado permanentemente",
     "contentDeleted": "El contenido seleccionado ha sido eliminado permanentemente",
-    "failedDeleteContent": "Error al eliminar el contenido"
+    "failedDeleteContent": "Error al eliminar el contenido",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Inicia sesión con biometría o una llave de seguridad en lugar de una contraseña.",
+      "noPasskeys": "Aún no hay passkeys registradas.",
+      "unnamed": "Passkey sin nombre",
+      "singleDevice": "Solo este dispositivo",
+      "multiDevice": "Sincronizado entre dispositivos",
+      "register": "Registrar Passkey",
+      "registering": "Registrando...",
+      "registerSuccess": "Passkey registrada correctamente",
+      "registerFailed": "Error al registrar passkey",
+      "delete": "Eliminar",
+      "deleteSuccess": "Passkey eliminada",
+      "deleteFailed": "Error al eliminar passkey",
+      "rename": "Renombrar Passkey",
+      "renameFailed": "Error al renombrar passkey",
+      "nameLabel": "Nombre",
+      "namePlaceholder": "ej. MacBook Pro, iPhone",
+      "cancel": "Cancelar",
+      "save": "Guardar",
+      "confirmDeleteTitle": "Eliminar Passkey",
+      "confirmDelete": "¿Está seguro de que desea eliminar esta passkey? Ya no podrá iniciar sesión con ella."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Equipo",
     "title": "Equipo",

+ 5 - 1
messages/fr/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Vous n'avez pas de compte ?",
     "noAccount": "Vous n'avez pas de compte ?",
     "createOne": "Créer un compte",
     "createOne": "Créer un compte",
     "termsAgreement": "En utilisant ce service, vous acceptez nos",
     "termsAgreement": "En utilisant ce service, vous acceptez nos",
+    "or": "ou",
+    "passkey": "Se connecter avec Passkey",
+    "passkeyDescription": "Utilisez votre empreinte digitale, votre visage ou votre clé de sécurité",
     "errors": {
     "errors": {
       "tooManyAttempts": "Trop de tentatives, veuillez réessayer plus tard.",
       "tooManyAttempts": "Trop de tentatives, veuillez réessayer plus tard.",
-      "invalidCredentials": "Adresse e-mail ou mot de passe invalide"
+      "invalidCredentials": "Adresse e-mail ou mot de passe invalide",
+      "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/fr/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Tapez <bold>delete my data</bold> pour confirmer",
     "deleteContentConfirmPrompt": "Tapez <bold>delete my data</bold> pour confirmer",
     "deleteSelected": "Supprimer {count} selectionnes",
     "deleteSelected": "Supprimer {count} selectionnes",
     "contentDeleted": "Le contenu selectionne a ete definitivement supprime",
     "contentDeleted": "Le contenu selectionne a ete definitivement supprime",
-    "failedDeleteContent": "Echec de la suppression du contenu"
+    "failedDeleteContent": "Echec de la suppression du contenu",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Connectez-vous avec la biométrie ou une clé de sécurité au lieu d'un mot de passe.",
+      "noPasskeys": "Aucune passkey enregistrée.",
+      "unnamed": "Passkey sans nom",
+      "singleDevice": "Cet appareil uniquement",
+      "multiDevice": "Synchronisé entre appareils",
+      "register": "Enregistrer une Passkey",
+      "registering": "Enregistrement...",
+      "registerSuccess": "Passkey enregistrée avec succès",
+      "registerFailed": "Échec de l'enregistrement de la passkey",
+      "delete": "Supprimer",
+      "deleteSuccess": "Passkey supprimée",
+      "deleteFailed": "Échec de la suppression de la passkey",
+      "rename": "Renommer la Passkey",
+      "renameFailed": "Échec du renommage de la passkey",
+      "nameLabel": "Nom",
+      "namePlaceholder": "ex. MacBook Pro, iPhone",
+      "cancel": "Annuler",
+      "save": "Enregistrer",
+      "confirmDeleteTitle": "Supprimer la Passkey",
+      "confirmDelete": "Êtes-vous sûr de vouloir supprimer cette passkey ? Vous ne pourrez plus vous connecter avec."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Equipe",
     "title": "Equipe",

+ 5 - 1
messages/it/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Non ha un account?",
     "noAccount": "Non ha un account?",
     "createOne": "Ne crei uno",
     "createOne": "Ne crei uno",
     "termsAgreement": "Utilizzando questo servizio accetta i nostri",
     "termsAgreement": "Utilizzando questo servizio accetta i nostri",
+    "or": "o",
+    "passkey": "Accedi con Passkey",
+    "passkeyDescription": "Usa la tua impronta digitale, il viso o una chiave di sicurezza",
     "errors": {
     "errors": {
       "tooManyAttempts": "Troppi tentativi, riprovi più tardi.",
       "tooManyAttempts": "Troppi tentativi, riprovi più tardi.",
-      "invalidCredentials": "Email o password non validi"
+      "invalidCredentials": "Email o password non validi",
+      "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/it/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Digita <bold>delete my data</bold> per confermare",
     "deleteContentConfirmPrompt": "Digita <bold>delete my data</bold> per confermare",
     "deleteSelected": "Elimina {count} selezionati",
     "deleteSelected": "Elimina {count} selezionati",
     "contentDeleted": "I contenuti selezionati sono stati eliminati permanentemente",
     "contentDeleted": "I contenuti selezionati sono stati eliminati permanentemente",
-    "failedDeleteContent": "Eliminazione contenuti non riuscita"
+    "failedDeleteContent": "Eliminazione contenuti non riuscita",
+    "passkey": {
+      "title": "Passkey",
+      "description": "Accedi con la biometria o una chiave di sicurezza invece della password.",
+      "noPasskeys": "Nessuna passkey registrata.",
+      "unnamed": "Passkey senza nome",
+      "singleDevice": "Solo questo dispositivo",
+      "multiDevice": "Sincronizzato tra dispositivi",
+      "register": "Registra Passkey",
+      "registering": "Registrazione...",
+      "registerSuccess": "Passkey registrata con successo",
+      "registerFailed": "Registrazione passkey fallita",
+      "delete": "Elimina",
+      "deleteSuccess": "Passkey eliminata",
+      "deleteFailed": "Eliminazione passkey fallita",
+      "rename": "Rinomina Passkey",
+      "renameFailed": "Rinomina passkey fallita",
+      "nameLabel": "Nome",
+      "namePlaceholder": "es. MacBook Pro, iPhone",
+      "cancel": "Annulla",
+      "save": "Salva",
+      "confirmDeleteTitle": "Elimina Passkey",
+      "confirmDelete": "Sei sicuro di voler eliminare questa passkey? Non potrai più accedere con essa."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Team",
     "title": "Team",

+ 5 - 1
messages/nb/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Har du ikke en konto?",
     "noAccount": "Har du ikke en konto?",
     "createOne": "Opprett en",
     "createOne": "Opprett en",
     "termsAgreement": "Ved å bruke denne tjenesten godtar du våre",
     "termsAgreement": "Ved å bruke denne tjenesten godtar du våre",
+    "or": "eller",
+    "passkey": "Logg inn med Passkey",
+    "passkeyDescription": "Bruk fingeravtrykk, ansikt eller sikkerhetsnøkkel",
     "errors": {
     "errors": {
       "tooManyAttempts": "For mange forsøk, prøv igjen senere.",
       "tooManyAttempts": "For mange forsøk, prøv igjen senere.",
-      "invalidCredentials": "Ugyldig e-post eller passord"
+      "invalidCredentials": "Ugyldig e-post eller passord",
+      "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/nb/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Skriv <bold>delete my data</bold> for a bekrefte",
     "deleteContentConfirmPrompt": "Skriv <bold>delete my data</bold> for a bekrefte",
     "deleteSelected": "Slett {count} valgte",
     "deleteSelected": "Slett {count} valgte",
     "contentDeleted": "Valgt innhold har blitt permanent slettet",
     "contentDeleted": "Valgt innhold har blitt permanent slettet",
-    "failedDeleteContent": "Kunne ikke slette innhold"
+    "failedDeleteContent": "Kunne ikke slette innhold",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Logg inn med biometri eller en sikkerhetsnøkkel i stedet for passord.",
+      "noPasskeys": "Ingen passkeys registrert ennå.",
+      "unnamed": "Navnløs passkey",
+      "singleDevice": "Kun denne enheten",
+      "multiDevice": "Synkronisert på tvers av enheter",
+      "register": "Registrer Passkey",
+      "registering": "Registrerer...",
+      "registerSuccess": "Passkey registrert",
+      "registerFailed": "Kunne ikke registrere passkey",
+      "delete": "Slett",
+      "deleteSuccess": "Passkey slettet",
+      "deleteFailed": "Kunne ikke slette passkey",
+      "rename": "Gi nytt navn til Passkey",
+      "renameFailed": "Kunne ikke gi nytt navn til passkey",
+      "nameLabel": "Navn",
+      "namePlaceholder": "f.eks. MacBook Pro, iPhone",
+      "cancel": "Avbryt",
+      "save": "Lagre",
+      "confirmDeleteTitle": "Slett Passkey",
+      "confirmDelete": "Er du sikker på at du vil slette denne passkeyen? Du kan ikke logge inn med den lenger."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Team",
     "title": "Team",

+ 5 - 1
messages/nl/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Heeft u nog geen account?",
     "noAccount": "Heeft u nog geen account?",
     "createOne": "Maak er een aan",
     "createOne": "Maak er een aan",
     "termsAgreement": "Door gebruik te maken van deze dienst gaat u akkoord met onze",
     "termsAgreement": "Door gebruik te maken van deze dienst gaat u akkoord met onze",
+    "or": "of",
+    "passkey": "Inloggen met Passkey",
+    "passkeyDescription": "Gebruik uw vingerafdruk, gezicht of beveiligingssleutel",
     "errors": {
     "errors": {
       "tooManyAttempts": "Te veel pogingen, probeer het later opnieuw.",
       "tooManyAttempts": "Te veel pogingen, probeer het later opnieuw.",
-      "invalidCredentials": "Ongeldig e-mailadres of wachtwoord"
+      "invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
+      "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/nl/settings.json

@@ -124,7 +124,30 @@
     "deleteContentConfirmPrompt": "Typ <bold>delete my data</bold> om te bevestigen",
     "deleteContentConfirmPrompt": "Typ <bold>delete my data</bold> om te bevestigen",
     "deleteSelected": "{count} geselecteerde verwijderen",
     "deleteSelected": "{count} geselecteerde verwijderen",
     "contentDeleted": "Geselecteerde inhoud is permanent verwijderd",
     "contentDeleted": "Geselecteerde inhoud is permanent verwijderd",
-    "failedDeleteContent": "Inhoud verwijderen mislukt"
+    "failedDeleteContent": "Inhoud verwijderen mislukt",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Log in met biometrie of een beveiligingssleutel in plaats van een wachtwoord.",
+      "noPasskeys": "Nog geen passkeys geregistreerd.",
+      "unnamed": "Naamloze passkey",
+      "singleDevice": "Alleen dit apparaat",
+      "multiDevice": "Gesynchroniseerd tussen apparaten",
+      "register": "Passkey registreren",
+      "registering": "Registreren...",
+      "registerSuccess": "Passkey succesvol geregistreerd",
+      "registerFailed": "Passkey registreren mislukt",
+      "delete": "Verwijderen",
+      "deleteSuccess": "Passkey verwijderd",
+      "deleteFailed": "Passkey verwijderen mislukt",
+      "rename": "Passkey hernoemen",
+      "renameFailed": "Passkey hernoemen mislukt",
+      "nameLabel": "Naam",
+      "namePlaceholder": "bijv. MacBook Pro, iPhone",
+      "cancel": "Annuleren",
+      "save": "Opslaan",
+      "confirmDeleteTitle": "Passkey verwijderen",
+      "confirmDelete": "Weet u zeker dat u deze passkey wilt verwijderen? U kunt er niet meer mee inloggen."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Team",
     "title": "Team",

+ 5 - 1
messages/pl/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Nie masz konta?",
     "noAccount": "Nie masz konta?",
     "createOne": "Utwórz konto",
     "createOne": "Utwórz konto",
     "termsAgreement": "Korzystając z tego serwisu, akceptujesz nasz",
     "termsAgreement": "Korzystając z tego serwisu, akceptujesz nasz",
+    "or": "lub",
+    "passkey": "Zaloguj się za pomocą Passkey",
+    "passkeyDescription": "Użyj odcisku palca, twarzy lub klucza bezpieczeństwa",
     "errors": {
     "errors": {
       "tooManyAttempts": "Zbyt wiele prób, spróbuj ponownie później.",
       "tooManyAttempts": "Zbyt wiele prób, spróbuj ponownie później.",
-      "invalidCredentials": "Nieprawidłowy e-mail lub hasło"
+      "invalidCredentials": "Nieprawidłowy e-mail lub hasło",
+      "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/pl/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Wpisz <bold>delete my data</bold> aby potwierdzic",
     "deleteContentConfirmPrompt": "Wpisz <bold>delete my data</bold> aby potwierdzic",
     "deleteSelected": "Usun {count} wybranych",
     "deleteSelected": "Usun {count} wybranych",
     "contentDeleted": "Wybrana zawartosc zostala trwale usunieta",
     "contentDeleted": "Wybrana zawartosc zostala trwale usunieta",
-    "failedDeleteContent": "Nie udalo sie usunac zawartosci"
+    "failedDeleteContent": "Nie udalo sie usunac zawartosci",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Loguj się za pomocą biometrii lub klucza bezpieczeństwa zamiast hasła.",
+      "noPasskeys": "Nie zarejestrowano jeszcze żadnych passkeys.",
+      "unnamed": "Passkey bez nazwy",
+      "singleDevice": "Tylko to urządzenie",
+      "multiDevice": "Zsynchronizowany między urządzeniami",
+      "register": "Zarejestruj Passkey",
+      "registering": "Rejestrowanie...",
+      "registerSuccess": "Passkey zarejestrowany pomyślnie",
+      "registerFailed": "Nie udało się zarejestrować passkey",
+      "delete": "Usuń",
+      "deleteSuccess": "Passkey usunięty",
+      "deleteFailed": "Nie udało się usunąć passkey",
+      "rename": "Zmień nazwę Passkey",
+      "renameFailed": "Nie udało się zmienić nazwy passkey",
+      "nameLabel": "Nazwa",
+      "namePlaceholder": "np. MacBook Pro, iPhone",
+      "cancel": "Anuluj",
+      "save": "Zapisz",
+      "confirmDeleteTitle": "Usuń Passkey",
+      "confirmDelete": "Czy na pewno chcesz usunąć ten passkey? Nie będziesz mógł się nim logować."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Zespol",
     "title": "Zespol",

+ 5 - 1
messages/pt-BR/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Ainda n\u00e3o tem uma conta?",
     "noAccount": "Ainda n\u00e3o tem uma conta?",
     "createOne": "Crie uma",
     "createOne": "Crie uma",
     "termsAgreement": "Ao utilizar este servi\u00e7o, voc\u00ea concorda com nossos",
     "termsAgreement": "Ao utilizar este servi\u00e7o, voc\u00ea concorda com nossos",
+    "or": "ou",
+    "passkey": "Entrar com Passkey",
+    "passkeyDescription": "Use sua impressão digital, rosto ou chave de segurança",
     "errors": {
     "errors": {
       "tooManyAttempts": "Muitas tentativas. Tente novamente mais tarde.",
       "tooManyAttempts": "Muitas tentativas. Tente novamente mais tarde.",
-      "invalidCredentials": "E-mail ou senha inv\u00e1lidos"
+      "invalidCredentials": "E-mail ou senha inv\u00e1lidos",
+      "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/pt-BR/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Digite <bold>delete my data</bold> para confirmar",
     "deleteContentConfirmPrompt": "Digite <bold>delete my data</bold> para confirmar",
     "deleteSelected": "Excluir {count} selecionados",
     "deleteSelected": "Excluir {count} selecionados",
     "contentDeleted": "O conteudo selecionado foi excluido permanentemente",
     "contentDeleted": "O conteudo selecionado foi excluido permanentemente",
-    "failedDeleteContent": "Falha ao excluir o conteudo"
+    "failedDeleteContent": "Falha ao excluir o conteudo",
+    "passkey": {
+      "title": "Passkeys",
+      "description": "Entre com biometria ou uma chave de segurança em vez de uma senha.",
+      "noPasskeys": "Nenhuma passkey registrada ainda.",
+      "unnamed": "Passkey sem nome",
+      "singleDevice": "Apenas este dispositivo",
+      "multiDevice": "Sincronizado entre dispositivos",
+      "register": "Registrar Passkey",
+      "registering": "Registrando...",
+      "registerSuccess": "Passkey registrada com sucesso",
+      "registerFailed": "Falha ao registrar passkey",
+      "delete": "Excluir",
+      "deleteSuccess": "Passkey excluída",
+      "deleteFailed": "Falha ao excluir passkey",
+      "rename": "Renomear Passkey",
+      "renameFailed": "Falha ao renomear passkey",
+      "nameLabel": "Nome",
+      "namePlaceholder": "ex. MacBook Pro, iPhone",
+      "cancel": "Cancelar",
+      "save": "Salvar",
+      "confirmDeleteTitle": "Excluir Passkey",
+      "confirmDelete": "Tem certeza de que deseja excluir esta passkey? Você não poderá mais entrar com ela."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Equipe",
     "title": "Equipe",

+ 5 - 1
messages/tr/auth.json

@@ -7,9 +7,13 @@
     "noAccount": "Hesabınız yok mu?",
     "noAccount": "Hesabınız yok mu?",
     "createOne": "Hesap oluşturun",
     "createOne": "Hesap oluşturun",
     "termsAgreement": "Bu hizmeti kullanarak şunları kabul etmiş olursunuz:",
     "termsAgreement": "Bu hizmeti kullanarak şunları kabul etmiş olursunuz:",
+    "or": "veya",
+    "passkey": "Passkey ile Giriş Yap",
+    "passkeyDescription": "Parmak izinizi, yüzünüzü veya güvenlik anahtarınızı kullanın",
     "errors": {
     "errors": {
       "tooManyAttempts": "Çok fazla deneme yapıldı, lütfen daha sonra tekrar deneyin.",
       "tooManyAttempts": "Çok fazla deneme yapıldı, lütfen daha sonra tekrar deneyin.",
-      "invalidCredentials": "Geçersiz e-posta veya şifre"
+      "invalidCredentials": "Geçersiz e-posta veya şifre",
+      "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın."
     }
     }
   },
   },
   "signUp": {
   "signUp": {

+ 24 - 1
messages/tr/settings.json

@@ -151,7 +151,30 @@
     "deleteContentConfirmPrompt": "Onaylamak için <bold>delete my data</bold> yazın",
     "deleteContentConfirmPrompt": "Onaylamak için <bold>delete my data</bold> yazın",
     "deleteSelected": "{count} seçili sil",
     "deleteSelected": "{count} seçili sil",
     "contentDeleted": "Seçilen içerik kalıcı olarak silindi",
     "contentDeleted": "Seçilen içerik kalıcı olarak silindi",
-    "failedDeleteContent": "İçerik silinemedi"
+    "failedDeleteContent": "İçerik silinemedi",
+    "passkey": {
+      "title": "Passkey'ler",
+      "description": "Parola yerine biyometri veya güvenlik anahtarı ile giriş yapın.",
+      "noPasskeys": "Henüz kayıtlı passkey yok.",
+      "unnamed": "İsimsiz passkey",
+      "singleDevice": "Yalnızca bu cihaz",
+      "multiDevice": "Cihazlar arası senkronize",
+      "register": "Passkey Kaydet",
+      "registering": "Kaydediliyor...",
+      "registerSuccess": "Passkey başarıyla kaydedildi",
+      "registerFailed": "Passkey kaydedilemedi",
+      "delete": "Sil",
+      "deleteSuccess": "Passkey silindi",
+      "deleteFailed": "Passkey silinemedi",
+      "rename": "Passkey'i Yeniden Adlandır",
+      "renameFailed": "Passkey yeniden adlandırılamadı",
+      "nameLabel": "Ad",
+      "namePlaceholder": "ör. MacBook Pro, iPhone",
+      "cancel": "İptal",
+      "save": "Kaydet",
+      "confirmDeleteTitle": "Passkey'i Sil",
+      "confirmDelete": "Bu passkey'i silmek istediğinizden emin misiniz? Artık bununla giriş yapamayacaksınız."
+    }
   },
   },
   "team": {
   "team": {
     "title": "Ekip",
     "title": "Ekip",

+ 284 - 12
package-lock.json

@@ -10,6 +10,7 @@
       "license": "Elastic-2.0",
       "license": "Elastic-2.0",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-ses": "^3.992.0",
         "@aws-sdk/client-ses": "^3.992.0",
+        "@better-auth/passkey": "^1.4.19",
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/utilities": "^3.2.2",
         "@dnd-kit/utilities": "^3.2.2",
         "@hookform/resolvers": "^5.2.2",
         "@hookform/resolvers": "^5.2.2",
@@ -1409,9 +1410,9 @@
       }
       }
     },
     },
     "node_modules/@better-auth/core": {
     "node_modules/@better-auth/core": {
-      "version": "1.4.18",
-      "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz",
-      "integrity": "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==",
+      "version": "1.4.19",
+      "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.19.tgz",
+      "integrity": "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA==",
       "dependencies": {
       "dependencies": {
         "@standard-schema/spec": "^1.0.0",
         "@standard-schema/spec": "^1.0.0",
         "zod": "^4.3.5"
         "zod": "^4.3.5"
@@ -1425,16 +1426,35 @@
         "nanostores": "^1.0.1"
         "nanostores": "^1.0.1"
       }
       }
     },
     },
+    "node_modules/@better-auth/passkey": {
+      "version": "1.4.19",
+      "resolved": "https://registry.npmjs.org/@better-auth/passkey/-/passkey-1.4.19.tgz",
+      "integrity": "sha512-KzUUrMgw6FTxPt7lcD0ZDx4RYIX16aTx73mFtEdACDY40HBheyY+U092+kpTf4oK+PX/g7B9mpX7R8OHH/LeJw==",
+      "license": "MIT",
+      "dependencies": {
+        "@simplewebauthn/browser": "^13.1.2",
+        "@simplewebauthn/server": "^13.1.2",
+        "zod": "^4.3.5"
+      },
+      "peerDependencies": {
+        "@better-auth/core": "1.4.19",
+        "@better-auth/utils": "0.3.0",
+        "@better-fetch/fetch": "1.1.21",
+        "better-auth": "1.4.19",
+        "better-call": "1.1.8",
+        "nanostores": "^1.0.1"
+      }
+    },
     "node_modules/@better-auth/telemetry": {
     "node_modules/@better-auth/telemetry": {
-      "version": "1.4.18",
-      "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.18.tgz",
-      "integrity": "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==",
+      "version": "1.4.19",
+      "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.19.tgz",
+      "integrity": "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==",
       "dependencies": {
       "dependencies": {
         "@better-auth/utils": "0.3.0",
         "@better-auth/utils": "0.3.0",
         "@better-fetch/fetch": "1.1.21"
         "@better-fetch/fetch": "1.1.21"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
-        "@better-auth/core": "1.4.18"
+        "@better-auth/core": "1.4.19"
       }
       }
     },
     },
     "node_modules/@better-auth/utils": {
     "node_modules/@better-auth/utils": {
@@ -2596,6 +2616,12 @@
         "tslib": "^2.8.1"
         "tslib": "^2.8.1"
       }
       }
     },
     },
+    "node_modules/@hexagon/base64": {
+      "version": "1.1.28",
+      "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+      "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+      "license": "MIT"
+    },
     "node_modules/@hono/node-server": {
     "node_modules/@hono/node-server": {
       "version": "1.19.9",
       "version": "1.19.9",
       "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
       "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
@@ -3306,6 +3332,12 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
       }
     },
     },
+    "node_modules/@levischuck/tiny-cbor": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+      "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
+      "license": "MIT"
+    },
     "node_modules/@modelcontextprotocol/sdk": {
     "node_modules/@modelcontextprotocol/sdk": {
       "version": "1.26.0",
       "version": "1.26.0",
       "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
       "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
@@ -3942,6 +3974,165 @@
         "pako": "^1.0.10"
         "pako": "^1.0.10"
       }
       }
     },
     },
+    "node_modules/@peculiar/asn1-android": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
+      "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-cms": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz",
+      "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "@peculiar/asn1-x509-attr": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-csr": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz",
+      "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-ecc": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz",
+      "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-pfx": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz",
+      "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-cms": "^2.6.1",
+        "@peculiar/asn1-pkcs8": "^2.6.1",
+        "@peculiar/asn1-rsa": "^2.6.1",
+        "@peculiar/asn1-schema": "^2.6.0",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-pkcs8": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz",
+      "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-pkcs9": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz",
+      "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-cms": "^2.6.1",
+        "@peculiar/asn1-pfx": "^2.6.1",
+        "@peculiar/asn1-pkcs8": "^2.6.1",
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "@peculiar/asn1-x509-attr": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-rsa": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz",
+      "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-schema": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
+      "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
+      "license": "MIT",
+      "dependencies": {
+        "asn1js": "^3.0.6",
+        "pvtsutils": "^1.3.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-x509": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz",
+      "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "asn1js": "^3.0.6",
+        "pvtsutils": "^1.3.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/asn1-x509-attr": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz",
+      "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "asn1js": "^3.0.6",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/@peculiar/x509": {
+      "version": "1.14.3",
+      "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
+      "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
+      "license": "MIT",
+      "dependencies": {
+        "@peculiar/asn1-cms": "^2.6.0",
+        "@peculiar/asn1-csr": "^2.6.0",
+        "@peculiar/asn1-ecc": "^2.6.0",
+        "@peculiar/asn1-pkcs9": "^2.6.0",
+        "@peculiar/asn1-rsa": "^2.6.0",
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.0",
+        "pvtsutils": "^1.3.6",
+        "reflect-metadata": "^0.2.2",
+        "tslib": "^2.8.1",
+        "tsyringe": "^4.10.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
     "node_modules/@pkgjs/parseargs": {
     "node_modules/@pkgjs/parseargs": {
       "version": "0.11.0",
       "version": "0.11.0",
       "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
       "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -6146,6 +6337,31 @@
         "node": ">=12.*"
         "node": ">=12.*"
       }
       }
     },
     },
+    "node_modules/@simplewebauthn/browser": {
+      "version": "13.2.2",
+      "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
+      "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
+      "license": "MIT"
+    },
+    "node_modules/@simplewebauthn/server": {
+      "version": "13.2.3",
+      "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.3.tgz",
+      "integrity": "sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==",
+      "license": "MIT",
+      "dependencies": {
+        "@hexagon/base64": "^1.1.27",
+        "@levischuck/tiny-cbor": "^0.2.2",
+        "@peculiar/asn1-android": "^2.6.0",
+        "@peculiar/asn1-ecc": "^2.6.1",
+        "@peculiar/asn1-rsa": "^2.6.1",
+        "@peculiar/asn1-schema": "^2.6.0",
+        "@peculiar/asn1-x509": "^2.6.1",
+        "@peculiar/x509": "^1.14.3"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
     "node_modules/@sindresorhus/merge-streams": {
     "node_modules/@sindresorhus/merge-streams": {
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -8425,6 +8641,20 @@
         "dequal": "^2.0.3"
         "dequal": "^2.0.3"
       }
       }
     },
     },
+    "node_modules/asn1js": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
+      "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "pvtsutils": "^1.3.6",
+        "pvutils": "^1.1.3",
+        "tslib": "^2.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/assertion-error": {
     "node_modules/assertion-error": {
       "version": "2.0.1",
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -8570,13 +8800,13 @@
       }
       }
     },
     },
     "node_modules/better-auth": {
     "node_modules/better-auth": {
-      "version": "1.4.18",
-      "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.18.tgz",
-      "integrity": "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==",
+      "version": "1.4.19",
+      "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.19.tgz",
+      "integrity": "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@better-auth/core": "1.4.18",
-        "@better-auth/telemetry": "1.4.18",
+        "@better-auth/core": "1.4.19",
+        "@better-auth/telemetry": "1.4.19",
         "@better-auth/utils": "0.3.0",
         "@better-auth/utils": "0.3.0",
         "@better-fetch/fetch": "1.1.21",
         "@better-fetch/fetch": "1.1.21",
         "@noble/ciphers": "^2.0.0",
         "@noble/ciphers": "^2.0.0",
@@ -14056,6 +14286,24 @@
       ],
       ],
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/pvtsutils": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+      "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/pvutils": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+      "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
     "node_modules/qrcode.react": {
     "node_modules/qrcode.react": {
       "version": "4.2.0",
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
       "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
@@ -14524,6 +14772,12 @@
         "redux": "^5.0.0"
         "redux": "^5.0.0"
       }
       }
     },
     },
+    "node_modules/reflect-metadata": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+      "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+      "license": "Apache-2.0"
+    },
     "node_modules/require-directory": {
     "node_modules/require-directory": {
       "version": "2.1.1",
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -15794,6 +16048,24 @@
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "license": "0BSD"
       "license": "0BSD"
     },
     },
+    "node_modules/tsyringe": {
+      "version": "4.10.0",
+      "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+      "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^1.9.3"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/tsyringe/node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+      "license": "0BSD"
+    },
     "node_modules/tw-animate-css": {
     "node_modules/tw-animate-css": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
       "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",

+ 1 - 0
package.json

@@ -18,6 +18,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@aws-sdk/client-ses": "^3.992.0",
     "@aws-sdk/client-ses": "^3.992.0",
+    "@better-auth/passkey": "^1.4.19",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/utilities": "^3.2.2",
     "@dnd-kit/utilities": "^3.2.2",
     "@hookform/resolvers": "^5.2.2",
     "@hookform/resolvers": "^5.2.2",

+ 34 - 0
prisma/migrations/20260227185301_passkeys/migration.sql

@@ -0,0 +1,34 @@
+/*
+  Warnings:
+
+  - A unique constraint covering the columns `[identifier]` on the table `verifications` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- CreateTable
+CREATE TABLE "passkeys" (
+    "id" TEXT NOT NULL,
+    "name" TEXT,
+    "publicKey" TEXT NOT NULL,
+    "userId" TEXT NOT NULL,
+    "credentialID" TEXT NOT NULL,
+    "counter" INTEGER NOT NULL,
+    "deviceType" TEXT NOT NULL,
+    "backedUp" BOOLEAN NOT NULL,
+    "transports" TEXT,
+    "aaguid" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "passkeys_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "passkeys_credentialID_key" ON "passkeys"("credentialID");
+
+-- CreateIndex
+CREATE INDEX "passkeys_userId_idx" ON "passkeys"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "verifications_identifier_key" ON "verifications"("identifier");
+
+-- AddForeignKey
+ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 20 - 1
prisma/schema.prisma

@@ -31,6 +31,7 @@ model User {
   quotes                 Quote[]
   quotes                 Quote[]
   customFieldDefinitions CustomFieldDefinition[]
   customFieldDefinitions CustomFieldDefinition[]
   twoFactor              TwoFactor?
   twoFactor              TwoFactor?
+  passkeys               Passkey[]
   organizationMembers    OrganizationMember[]
   organizationMembers    OrganizationMember[]
 
 
   @@map("users")
   @@map("users")
@@ -90,7 +91,7 @@ model Account {
 
 
 model Verification {
 model Verification {
   id         String    @id @default(cuid())
   id         String    @id @default(cuid())
-  identifier String
+  identifier String   @unique
   value      String
   value      String
   expiresAt  DateTime
   expiresAt  DateTime
   createdAt  DateTime? @default(now())
   createdAt  DateTime? @default(now())
@@ -109,6 +110,24 @@ model TwoFactor {
   @@map("two_factor")
   @@map("two_factor")
 }
 }
 
 
+model Passkey {
+  id           String   @id @default(cuid())
+  name         String?
+  publicKey    String
+  userId       String
+  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  credentialID String   @unique
+  counter      Int
+  deviceType   String
+  backedUp     Boolean
+  transports   String?
+  aaguid       String?
+  createdAt    DateTime @default(now())
+
+  @@index([userId])
+  @@map("passkeys")
+}
+
 model Vehicle {
 model Vehicle {
   id                     String    @id @default(cuid())
   id                     String    @id @default(cuid())
   make                   String
   make                   String

+ 341 - 0
src/__tests__/features/auth/passkey-settings.test.tsx

@@ -0,0 +1,341 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// Mock lucide-react icons
+vi.mock("lucide-react", () => ({
+  Fingerprint: () => <svg data-testid="icon-fingerprint" />,
+  Loader2: () => <svg data-testid="icon-loader" />,
+  Pencil: () => <svg data-testid="icon-pencil" />,
+  Plus: () => <svg data-testid="icon-plus" />,
+  Trash2: () => <svg data-testid="icon-trash" />,
+}));
+
+// Mock UI components (pass-through)
+vi.mock("@/components/ui/card", () => ({
+  Card: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+    <div {...props}>{children}</div>
+  ),
+  CardContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+    <div {...props}>{children}</div>
+  ),
+  CardHeader: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+    <div {...props}>{children}</div>
+  ),
+  CardTitle: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
+    <h3 {...props}>{children}</h3>
+  ),
+}));
+vi.mock("@/components/ui/dialog", () => ({
+  Dialog: ({
+    children,
+    open,
+  }: {
+    children: React.ReactNode;
+    open?: boolean;
+    onOpenChange?: (open: boolean) => void;
+  }) => (open ? <div data-testid="dialog">{children}</div> : null),
+  DialogContent: ({ children }: { children: React.ReactNode; className?: string }) => (
+    <div>{children}</div>
+  ),
+  DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
+  DialogFooter: ({ children }: { children: React.ReactNode; className?: string }) => (
+    <div>{children}</div>
+  ),
+  DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  DialogTitle: ({ children }: { children: React.ReactNode; className?: string }) => (
+    <h2>{children}</h2>
+  ),
+}));
+vi.mock("@/components/ui/button", () => ({
+  Button: ({
+    children,
+    ...props
+  }: React.HTMLAttributes<HTMLButtonElement> & {
+    variant?: string;
+    size?: string;
+    disabled?: boolean;
+  }) => <button {...props}>{children}</button>,
+}));
+vi.mock("@/components/ui/input", () => ({
+  Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
+}));
+vi.mock("@/components/ui/label", () => ({
+  Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
+    <label {...props}>{children}</label>
+  ),
+}));
+vi.mock("@/components/ui/separator", () => ({
+  Separator: () => <hr />,
+}));
+
+// Mock sonner
+const mockToastError = vi.fn();
+const mockToastSuccess = vi.fn();
+vi.mock("sonner", () => ({
+  toast: {
+    error: (...args: unknown[]) => mockToastError(...args),
+    success: (...args: unknown[]) => mockToastSuccess(...args),
+  },
+}));
+
+// Mock authClient.passkey
+const mockListUserPasskeys = vi.fn();
+const mockAddPasskey = vi.fn();
+const mockDeletePasskey = vi.fn();
+const mockUpdatePasskey = vi.fn();
+vi.mock("@/lib/auth-client", () => ({
+  authClient: {
+    passkey: {
+      listUserPasskeys: (...args: unknown[]) => mockListUserPasskeys(...args),
+      addPasskey: (...args: unknown[]) => mockAddPasskey(...args),
+      deletePasskey: (...args: unknown[]) => mockDeletePasskey(...args),
+      updatePasskey: (...args: unknown[]) => mockUpdatePasskey(...args),
+    },
+  },
+}));
+
+import { PasskeySettings } from "@/features/settings/Components/passkey-settings";
+
+const MOCK_PASSKEY = {
+  id: "pk_1",
+  name: "MacBook Pro",
+  credentialID: "cred-abc",
+  deviceType: "multiDevice",
+  createdAt: new Date("2026-01-15"),
+};
+
+const MOCK_PASSKEY_SINGLE = {
+  id: "pk_2",
+  name: null,
+  credentialID: "cred-def",
+  deviceType: "singleDevice",
+  createdAt: new Date("2026-02-10"),
+};
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+describe("PasskeySettings", () => {
+  describe("initial load", () => {
+    it("shows empty state when no passkeys exist", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] });
+      render(<PasskeySettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/no passkeys registered/i)).toBeInTheDocument();
+      });
+    });
+
+    it("renders passkey list with name and device type", async () => {
+      mockListUserPasskeys.mockResolvedValue({
+        data: [MOCK_PASSKEY, MOCK_PASSKEY_SINGLE],
+      });
+      render(<PasskeySettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
+      });
+      expect(screen.getByText(/synced across devices/i)).toBeInTheDocument();
+      expect(screen.getByText(/unnamed passkey/i)).toBeInTheDocument();
+      expect(screen.getByText(/this device only/i)).toBeInTheDocument();
+    });
+
+    it("shows title and description", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] });
+      render(<PasskeySettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText("Passkeys")).toBeInTheDocument();
+      });
+      expect(
+        screen.getByText(/sign in with biometrics or a security key/i),
+      ).toBeInTheDocument();
+    });
+  });
+
+  describe("register passkey", () => {
+    it("calls addPasskey and shows success toast", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] });
+      mockAddPasskey.mockResolvedValue({});
+      // After registering, the list is fetched again
+      mockListUserPasskeys.mockResolvedValueOnce({ data: [] }).mockResolvedValueOnce({
+        data: [MOCK_PASSKEY],
+      });
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText(/register passkey/i)).toBeInTheDocument();
+      });
+
+      await userEvent.click(screen.getByText(/register passkey/i));
+
+      await waitFor(() => {
+        expect(mockAddPasskey).toHaveBeenCalled();
+      });
+      expect(mockToastSuccess).toHaveBeenCalledWith("Passkey registered successfully");
+    });
+
+    it("shows error toast when registration fails", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] });
+      mockAddPasskey.mockRejectedValue(new Error("WebAuthn not supported"));
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText(/register passkey/i)).toBeInTheDocument();
+      });
+
+      await userEvent.click(screen.getByText(/register passkey/i));
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith("Failed to register passkey");
+      });
+    });
+
+    it("shows error toast when addPasskey returns error", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] });
+      mockAddPasskey.mockResolvedValue({
+        error: { message: "Browser not supported" },
+      });
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText(/register passkey/i)).toBeInTheDocument();
+      });
+
+      await userEvent.click(screen.getByText(/register passkey/i));
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith("Browser not supported");
+      });
+    });
+  });
+
+  describe("delete passkey", () => {
+    it("opens confirmation dialog and deletes on confirm", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [MOCK_PASSKEY] });
+      mockDeletePasskey.mockResolvedValue({});
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
+      });
+
+      // Click the delete (trash) icon button
+      const trashIcon = screen.getByTestId("icon-trash");
+      await userEvent.click(trashIcon.closest("button")!);
+
+      // Confirmation dialog should appear
+      await waitFor(() => {
+        expect(
+          screen.getByText(/are you sure you want to delete this passkey/i),
+        ).toBeInTheDocument();
+      });
+
+      // Click the "Delete" button in dialog
+      const deleteButtons = screen.getAllByText("Delete");
+      const confirmButton = deleteButtons.find(
+        (btn) => btn.closest("[data-testid='dialog']") !== null,
+      );
+      await userEvent.click(confirmButton!);
+
+      await waitFor(() => {
+        expect(mockDeletePasskey).toHaveBeenCalledWith({ id: "pk_1" });
+      });
+      expect(mockToastSuccess).toHaveBeenCalledWith("Passkey deleted");
+    });
+
+    it("shows error toast when delete fails", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [MOCK_PASSKEY] });
+      mockDeletePasskey.mockRejectedValue(new Error("Server error"));
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
+      });
+
+      const trashIcon = screen.getByTestId("icon-trash");
+      await userEvent.click(trashIcon.closest("button")!);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText(/are you sure you want to delete this passkey/i),
+        ).toBeInTheDocument();
+      });
+
+      const deleteButtons = screen.getAllByText("Delete");
+      const confirmButton = deleteButtons.find(
+        (btn) => btn.closest("[data-testid='dialog']") !== null,
+      );
+      await userEvent.click(confirmButton!);
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith("Failed to delete passkey");
+      });
+    });
+  });
+
+  describe("rename passkey", () => {
+    it("opens rename dialog and updates name", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [MOCK_PASSKEY] });
+      mockUpdatePasskey.mockResolvedValue({});
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
+      });
+
+      // Click the pencil icon
+      const pencilIcon = screen.getByTestId("icon-pencil");
+      await userEvent.click(pencilIcon.closest("button")!);
+
+      // Rename dialog should open with pre-filled name
+      await waitFor(() => {
+        expect(screen.getByText(/rename passkey/i)).toBeInTheDocument();
+      });
+
+      const nameInput = screen.getByLabelText("Name");
+      expect(nameInput).toHaveValue("MacBook Pro");
+
+      // Clear and type a new name
+      await userEvent.clear(nameInput);
+      await userEvent.type(nameInput, "Work Laptop");
+
+      // Click Save
+      await userEvent.click(screen.getByText("Save"));
+
+      await waitFor(() => {
+        expect(mockUpdatePasskey).toHaveBeenCalledWith({
+          id: "pk_1",
+          name: "Work Laptop",
+        });
+      });
+    });
+
+    it("shows error toast when rename fails", async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [MOCK_PASSKEY] });
+      mockUpdatePasskey.mockRejectedValue(new Error("Server error"));
+
+      render(<PasskeySettings />);
+      await waitFor(() => {
+        expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
+      });
+
+      const pencilIcon = screen.getByTestId("icon-pencil");
+      await userEvent.click(pencilIcon.closest("button")!);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText("Name")).toBeInTheDocument();
+      });
+
+      await userEvent.clear(screen.getByLabelText("Name"));
+      await userEvent.type(screen.getByLabelText("Name"), "New Name");
+      await userEvent.click(screen.getByText("Save"));
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith("Failed to rename passkey");
+      });
+    });
+  });
+});

+ 156 - 0
src/__tests__/features/auth/passkey-signin.test.tsx

@@ -0,0 +1,156 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// Mock next/navigation
+const mockPush = vi.fn();
+const mockRefresh = vi.fn();
+const mockGet = vi.fn();
+vi.mock("next/navigation", () => ({
+  useSearchParams: () => ({ get: mockGet }),
+  useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
+  usePathname: () => "/auth/sign-in",
+}));
+
+// Mock next/link
+vi.mock("next/link", () => ({
+  default: ({ href, children }: { href: string; children: React.ReactNode }) => (
+    <a href={href}>{children}</a>
+  ),
+}));
+
+// Mock lucide-react icons
+vi.mock("lucide-react", () => ({
+  Fingerprint: () => <svg data-testid="icon-fingerprint" />,
+  Gauge: () => <svg data-testid="icon-gauge" />,
+  Loader2: () => <svg data-testid="icon-loader" />,
+  XCircle: () => <svg data-testid="icon-xcircle" />,
+}));
+
+// Mock UI components
+vi.mock("@/components/ui/button", () => ({
+  Button: ({
+    children,
+    ...props
+  }: React.HTMLAttributes<HTMLButtonElement> & {
+    variant?: string;
+    disabled?: boolean;
+    type?: string;
+  }) => <button {...props}>{children}</button>,
+}));
+vi.mock("@/components/ui/input", () => ({
+  Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
+}));
+vi.mock("@/components/ui/label", () => ({
+  Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
+    <label {...props}>{children}</label>
+  ),
+}));
+vi.mock("@/components/ui/separator", () => ({
+  Separator: () => <hr />,
+}));
+
+// Mock authClient
+const mockSignInEmail = vi.fn();
+const mockSignInPasskey = vi.fn();
+vi.mock("@/lib/auth-client", () => ({
+  signIn: {
+    email: (...args: unknown[]) => mockSignInEmail(...args),
+  },
+  authClient: {
+    signIn: {
+      passkey: (...args: unknown[]) => mockSignInPasskey(...args),
+    },
+  },
+}));
+
+import { SignInForm } from "@/app/(public)/auth/sign-in/sign-in-form";
+
+beforeEach(() => {
+  vi.resetAllMocks();
+  mockGet.mockReturnValue(null);
+});
+
+describe("SignInForm — Passkey", () => {
+  it("renders the passkey sign-in button", () => {
+    render(<SignInForm registrationDisabled={false} />);
+    expect(screen.getByText(/sign in with passkey/i)).toBeInTheDocument();
+  });
+
+  it("renders the 'or' separator", () => {
+    render(<SignInForm registrationDisabled={false} />);
+    expect(screen.getByText("or")).toBeInTheDocument();
+  });
+
+  it("sets webauthn autocomplete on email field", () => {
+    render(<SignInForm registrationDisabled={false} />);
+    const emailInput = screen.getByLabelText("Email");
+    expect(emailInput).toHaveAttribute("autocomplete", "username webauthn");
+  });
+
+  it("calls authClient.signIn.passkey and redirects on success", async () => {
+    mockSignInPasskey.mockResolvedValue({ error: null });
+    render(<SignInForm registrationDisabled={false} />);
+
+    await userEvent.click(screen.getByText(/sign in with passkey/i));
+
+    await waitFor(() => {
+      expect(mockSignInPasskey).toHaveBeenCalled();
+    });
+    expect(mockPush).toHaveBeenCalledWith("/");
+    expect(mockRefresh).toHaveBeenCalled();
+  });
+
+  it("redirects to custom redirect param on success", async () => {
+    mockGet.mockReturnValue("/dashboard");
+    mockSignInPasskey.mockResolvedValue({ error: null });
+    render(<SignInForm registrationDisabled={false} />);
+
+    await userEvent.click(screen.getByText(/sign in with passkey/i));
+
+    await waitFor(() => {
+      expect(mockPush).toHaveBeenCalledWith("/dashboard");
+    });
+  });
+
+  it("shows error when passkey sign-in returns an error", async () => {
+    mockSignInPasskey.mockResolvedValue({
+      error: { message: "Credential not found" },
+    });
+    render(<SignInForm registrationDisabled={false} />);
+
+    await userEvent.click(screen.getByText(/sign in with passkey/i));
+
+    await waitFor(() => {
+      expect(screen.getByText("Credential not found")).toBeInTheDocument();
+    });
+  });
+
+  it("shows fallback error when passkey sign-in throws", async () => {
+    mockSignInPasskey.mockRejectedValue(new Error("Network error"));
+    render(<SignInForm registrationDisabled={false} />);
+
+    await userEvent.click(screen.getByText(/sign in with passkey/i));
+
+    await waitFor(() => {
+      expect(screen.getByText(/passkey sign-in failed/i)).toBeInTheDocument();
+    });
+  });
+
+  it("email/password sign-in still works", async () => {
+    mockSignInEmail.mockResolvedValue({ error: null });
+    render(<SignInForm registrationDisabled={false} />);
+
+    await userEvent.type(screen.getByLabelText("Email"), "user@test.com");
+    await userEvent.type(screen.getByLabelText("Password"), "password123");
+    await userEvent.click(screen.getByText("Sign In"));
+
+    await waitFor(() => {
+      expect(mockSignInEmail).toHaveBeenCalledWith({
+        email: "user@test.com",
+        password: "password123",
+      });
+    });
+    expect(mockPush).toHaveBeenCalledWith("/");
+  });
+});

+ 4 - 0
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -19,6 +19,7 @@ import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
 import { Checkbox } from '@/components/ui/checkbox'
 import { Checkbox } from '@/components/ui/checkbox'
 import { AlertTriangle, Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
 import { AlertTriangle, Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
+import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
 import { QRCodeSVG } from 'qrcode.react'
 import { QRCodeSVG } from 'qrcode.react'
 import { updateEmail } from '@/features/settings/Actions/accountActions'
 import { updateEmail } from '@/features/settings/Actions/accountActions'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
@@ -589,6 +590,9 @@ export function AccountSettings({
         </DialogContent>
         </DialogContent>
       </Dialog>
       </Dialog>
 
 
+      {/* Passkeys */}
+      <PasskeySettings />
+
       {/* Danger Zone */}
       {/* Danger Zone */}
       <Card className="border-destructive/30 shadow-sm">
       <Card className="border-destructive/30 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">

+ 48 - 2
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -4,11 +4,12 @@ import { Suspense, useState } from 'react'
 import { useRouter, useSearchParams } from 'next/navigation'
 import { useRouter, useSearchParams } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
-import { signIn } from '@/lib/auth-client'
+import { signIn, authClient } from '@/lib/auth-client'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
-import { Gauge, Loader2, XCircle } from 'lucide-react'
+import { Separator } from '@/components/ui/separator'
+import { Fingerprint, Gauge, Loader2, XCircle } from 'lucide-react'
 
 
 function SignInFormInner({ registrationDisabled }: { registrationDisabled: boolean }) {
 function SignInFormInner({ registrationDisabled }: { registrationDisabled: boolean }) {
   const t = useTranslations('auth.signIn')
   const t = useTranslations('auth.signIn')
@@ -17,6 +18,7 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
   const [password, setPassword] = useState('')
   const [password, setPassword] = useState('')
   const [error, setError] = useState('')
   const [error, setError] = useState('')
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
+  const [passkeyLoading, setPasskeyLoading] = useState(false)
   const router = useRouter()
   const router = useRouter()
   const searchParams = useSearchParams()
   const searchParams = useSearchParams()
 
 
@@ -45,6 +47,25 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
     }
     }
   }
   }
 
 
+  const handlePasskeySignIn = async () => {
+    setPasskeyLoading(true)
+    setError('')
+    try {
+      const result = await authClient.signIn.passkey()
+      if (result?.error) {
+        setError(result.error.message || t('errors.passkeyFailed'))
+      } else {
+        const redirect = searchParams.get('redirect') || '/'
+        router.push(redirect)
+        router.refresh()
+      }
+    } catch {
+      setError(t('errors.passkeyFailed'))
+    } finally {
+      setPasskeyLoading(false)
+    }
+  }
+
   return (
   return (
     <div className="glass relative z-10 w-full max-w-md rounded-2xl p-8 shadow-2xl">
     <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-8 text-center">
@@ -75,6 +96,7 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
             value={email}
             value={email}
             onChange={(e) => setEmail(e.target.value)}
             onChange={(e) => setEmail(e.target.value)}
             required
             required
+            autoComplete="username webauthn"
             className="h-11 bg-background/50"
             className="h-11 bg-background/50"
           />
           />
         </div>
         </div>
@@ -103,6 +125,30 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
         </Button>
         </Button>
       </form>
       </form>
 
 
+      <div className="relative my-4">
+        <div className="absolute inset-0 flex items-center">
+          <Separator className="w-full" />
+        </div>
+        <div className="relative flex justify-center text-xs uppercase">
+          <span className="bg-background/50 px-2 text-muted-foreground">{t('or')}</span>
+        </div>
+      </div>
+
+      <Button
+        type="button"
+        variant="outline"
+        className="h-11 w-full"
+        disabled={passkeyLoading}
+        onClick={handlePasskeySignIn}
+      >
+        {passkeyLoading ? (
+          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+        ) : (
+          <Fingerprint className="mr-2 h-4 w-4" />
+        )}
+        {t('passkey')}
+      </Button>
+
       {!registrationDisabled && (
       {!registrationDisabled && (
         <p className="mt-6 text-center text-sm text-muted-foreground">
         <p className="mt-6 text-center text-sm text-muted-foreground">
           {t('noAccount')}{' '}
           {t('noAccount')}{' '}

+ 1 - 0
src/app/api/public/auth/[...all]/route.ts

@@ -13,6 +13,7 @@ const strictPrefixes: { prefix: string; limit: number; windowMs: number }[] = [
   { prefix: "/api/public/auth/sign-up", limit: 5, windowMs: 60_000 },
   { prefix: "/api/public/auth/sign-up", limit: 5, windowMs: 60_000 },
   { prefix: "/api/public/auth/request-password-reset", limit: 5, windowMs: 60_000 },
   { prefix: "/api/public/auth/request-password-reset", limit: 5, windowMs: 60_000 },
   { prefix: "/api/public/auth/reset-password", limit: 5, windowMs: 60_000 },
   { prefix: "/api/public/auth/reset-password", limit: 5, windowMs: 60_000 },
+  { prefix: "/api/public/auth/passkey", limit: 10, windowMs: 60_000 },
 ];
 ];
 
 
 const defaultConfig = { limit: 30, windowMs: 60_000 };
 const defaultConfig = { limit: 30, windowMs: 60_000 };

+ 2 - 2
src/features/email/Actions/emailActions.ts

@@ -148,8 +148,8 @@ export async function sendQuoteEmail(input: {
       ],
       ],
     });
     });
 
 
-    // Update quote status to "sent" if still draft
-    if (quote.status === "draft") {
+    // Update quote status to "sent" (skip if already accepted or converted)
+    if (quote.status !== "accepted" && quote.status !== "converted") {
       await db.quote.update({
       await db.quote.update({
         where: { id: quoteId },
         where: { id: quoteId },
         data: { status: "sent" },
         data: { status: "sent" },

+ 259 - 0
src/features/settings/Components/passkey-settings.tsx

@@ -0,0 +1,259 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { authClient } from '@/lib/auth-client'
+import { toast } from 'sonner'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import { Fingerprint, Loader2, Pencil, Plus, Trash2 } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+
+interface Passkey {
+  id: string
+  name: string | null
+  credentialID: string
+  deviceType: string
+  createdAt: Date
+}
+
+export function PasskeySettings() {
+  const t = useTranslations('settings')
+  const [passkeys, setPasskeys] = useState<Passkey[]>([])
+  const [loading, setLoading] = useState(true)
+  const [registering, setRegistering] = useState(false)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
+  const [renameDialogOpen, setRenameDialogOpen] = useState(false)
+  const [renamingPasskey, setRenamingPasskey] = useState<Passkey | null>(null)
+  const [newName, setNewName] = useState('')
+  const [saving, setSaving] = useState(false)
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+  const [passKeyToDelete, setPassKeyToDelete] = useState<Passkey | null>(null)
+
+  const fetchPasskeys = async () => {
+    try {
+      const result = await authClient.passkey.listUserPasskeys()
+      if (result.data) {
+        setPasskeys(result.data as Passkey[])
+      }
+    } catch {
+      // Silently fail on fetch
+    }
+    setLoading(false)
+  }
+
+  useEffect(() => {
+    fetchPasskeys()
+  }, [])
+
+  const handleRegister = async () => {
+    setRegistering(true)
+    try {
+      const result = await authClient.passkey.addPasskey()
+      if (result?.error) {
+        toast.error(result.error.message || t('account.passkey.registerFailed'))
+      } else {
+        toast.success(t('account.passkey.registerSuccess'))
+        fetchPasskeys()
+      }
+    } catch {
+      toast.error(t('account.passkey.registerFailed'))
+    }
+    setRegistering(false)
+  }
+
+  const handleDelete = async () => {
+    if (!passKeyToDelete) return
+    setDeletingId(passKeyToDelete.id)
+    try {
+      await authClient.passkey.deletePasskey({ id: passKeyToDelete.id })
+      toast.success(t('account.passkey.deleteSuccess'))
+      setPasskeys((prev) => prev.filter((p) => p.id !== passKeyToDelete.id))
+    } catch {
+      toast.error(t('account.passkey.deleteFailed'))
+    }
+    setDeletingId(null)
+    setDeleteDialogOpen(false)
+    setPassKeyToDelete(null)
+  }
+
+  const handleRename = async () => {
+    if (!renamingPasskey || !newName.trim()) return
+    setSaving(true)
+    try {
+      await authClient.passkey.updatePasskey({
+        id: renamingPasskey.id,
+        name: newName.trim(),
+      })
+      setPasskeys((prev) =>
+        prev.map((p) => (p.id === renamingPasskey.id ? { ...p, name: newName.trim() } : p))
+      )
+      setRenameDialogOpen(false)
+      setRenamingPasskey(null)
+    } catch {
+      toast.error(t('account.passkey.renameFailed'))
+    }
+    setSaving(false)
+  }
+
+  const formatDate = (dateStr: Date) => {
+    return new Date(dateStr).toLocaleDateString(undefined, {
+      year: 'numeric',
+      month: 'short',
+      day: 'numeric',
+    })
+  }
+
+  return (
+    <>
+      <Card className="border-0 shadow-sm">
+        <CardHeader className="flex flex-row items-center gap-3 pb-4">
+          <Fingerprint className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">{t('account.passkey.title')}</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <p className="text-sm text-muted-foreground">
+            {t('account.passkey.description')}
+          </p>
+
+          {loading ? (
+            <div className="flex items-center gap-2 py-4">
+              <Loader2 className="h-4 w-4 animate-spin" />
+            </div>
+          ) : passkeys.length === 0 ? (
+            <p className="text-sm text-muted-foreground italic">
+              {t('account.passkey.noPasskeys')}
+            </p>
+          ) : (
+            <div className="space-y-2">
+              {passkeys.map((pk) => (
+                <div
+                  key={pk.id}
+                  className="flex items-center justify-between rounded-lg border p-3"
+                >
+                  <div className="min-w-0 flex-1">
+                    <p className="text-sm font-medium">
+                      {pk.name || t('account.passkey.unnamed')}
+                    </p>
+                    <p className="text-xs text-muted-foreground">
+                      {pk.deviceType === 'singleDevice'
+                        ? t('account.passkey.singleDevice')
+                        : t('account.passkey.multiDevice')}{' '}
+                      &middot; {formatDate(pk.createdAt)}
+                    </p>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-8 w-8"
+                      onClick={() => {
+                        setRenamingPasskey(pk)
+                        setNewName(pk.name || '')
+                        setRenameDialogOpen(true)
+                      }}
+                    >
+                      <Pencil className="h-3.5 w-3.5" />
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-8 w-8 text-destructive hover:text-destructive"
+                      disabled={deletingId === pk.id}
+                      onClick={() => {
+                        setPassKeyToDelete(pk)
+                        setDeleteDialogOpen(true)
+                      }}
+                    >
+                      {deletingId === pk.id ? (
+                        <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                      ) : (
+                        <Trash2 className="h-3.5 w-3.5" />
+                      )}
+                    </Button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+
+          <Separator />
+          <Button onClick={handleRegister} disabled={registering}>
+            {registering ? (
+              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+            ) : (
+              <Plus className="mr-2 h-4 w-4" />
+            )}
+            {registering ? t('account.passkey.registering') : t('account.passkey.register')}
+          </Button>
+        </CardContent>
+      </Card>
+
+      {/* Rename Dialog */}
+      <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>{t('account.passkey.rename')}</DialogTitle>
+          </DialogHeader>
+          <div className="space-y-2 py-2">
+            <Label htmlFor="passkey-name">{t('account.passkey.nameLabel')}</Label>
+            <Input
+              id="passkey-name"
+              value={newName}
+              onChange={(e) => setNewName(e.target.value)}
+              placeholder={t('account.passkey.namePlaceholder')}
+              onKeyDown={(e) => {
+                if (e.key === 'Enter') handleRename()
+              }}
+              autoFocus
+            />
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
+              {t('account.passkey.cancel')}
+            </Button>
+            <Button onClick={handleRename} disabled={saving || !newName.trim()}>
+              {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t('account.passkey.save')}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Delete Confirmation Dialog */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>{t('account.passkey.confirmDeleteTitle')}</DialogTitle>
+            <DialogDescription>
+              {t('account.passkey.confirmDelete')}
+            </DialogDescription>
+          </DialogHeader>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
+              {t('account.passkey.cancel')}
+            </Button>
+            <Button
+              variant="destructive"
+              onClick={handleDelete}
+              disabled={deletingId !== null}
+            >
+              {deletingId && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t('account.passkey.delete')}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  )
+}

+ 2 - 0
src/lib/auth-client.ts

@@ -1,3 +1,4 @@
+import { passkeyClient } from "@better-auth/passkey/client";
 import { createAuthClient } from "better-auth/react";
 import { createAuthClient } from "better-auth/react";
 import { twoFactorClient } from "better-auth/plugins/two-factor";
 import { twoFactorClient } from "better-auth/plugins/two-factor";
 
 
@@ -9,6 +10,7 @@ export const authClient = createAuthClient({
         window.location.href = "/auth/verify-2fa";
         window.location.href = "/auth/verify-2fa";
       },
       },
     }),
     }),
+    passkeyClient(),
   ],
   ],
 });
 });
 
 

+ 6 - 0
src/lib/auth.ts

@@ -1,3 +1,4 @@
+import { passkey } from "@better-auth/passkey";
 import { betterAuth } from "better-auth";
 import { betterAuth } from "better-auth";
 import { prismaAdapter } from "better-auth/adapters/prisma";
 import { prismaAdapter } from "better-auth/adapters/prisma";
 import { nextCookies } from "better-auth/next-js";
 import { nextCookies } from "better-auth/next-js";
@@ -109,6 +110,11 @@ export const auth = betterAuth({
   },
   },
   plugins: [
   plugins: [
     twoFactor({ issuer: "Torqvoice" }),
     twoFactor({ issuer: "Torqvoice" }),
+    passkey({
+      rpID: baseURL ? new URL(baseURL).hostname : "localhost",
+      rpName: "Torqvoice",
+      origin: baseURL || "http://localhost:3000",
+    }),
     nextCookies(), // Must be last plugin
     nextCookies(), // Must be last plugin
   ],
   ],
 });
 });