Преглед изворни кода

Name a new passkey after the device it is made on (#534)

Bernt Christian Egeland пре 1 дан
родитељ
комит
578c3607cf

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

@@ -175,6 +175,25 @@ describe('PasskeySettings', () => {
       expect(mockToastSuccess).toHaveBeenCalledWith('Passkey registered successfully')
     })
 
+    it('names the new passkey after the device it is made on', async () => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] })
+      mockAddPasskey.mockResolvedValue({})
+      const ua = vi
+        .spyOn(navigator, 'userAgent', 'get')
+        .mockReturnValue(
+          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
+        )
+      render(<PasskeySettings />)
+      await waitFor(() => {
+        expect(screen.getByText(/register passkey/i)).toBeInTheDocument()
+      })
+      await userEvent.click(screen.getByText(/register passkey/i))
+      await waitFor(() => {
+        expect(mockAddPasskey).toHaveBeenCalledWith({ name: 'Chrome on Mac' })
+      })
+      ua.mockRestore()
+    })
+
     it('shows error toast when registration fails', async () => {
       mockListUserPasskeys.mockResolvedValue({ data: [] })
       mockAddPasskey.mockRejectedValue(new Error('WebAuthn not supported'))

+ 7 - 1
src/features/settings/Components/passkey-settings.tsx

@@ -18,6 +18,7 @@ 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'
+import { describeUserAgent } from '@/lib/user-agent-label'
 
 interface Passkey {
   id: string
@@ -59,7 +60,12 @@ export function PasskeySettings() {
   const handleRegister = async () => {
     setRegistering(true)
     try {
-      const result = await authClient.passkey.addPasskey()
+      // Named after the device it is made on, the way the signed-in devices
+      // list names it, so a second passkey is not another "Unnamed passkey".
+      const label = describeUserAgent(navigator.userAgent)
+      const result = await authClient.passkey.addPasskey(
+        label === 'Unknown device' ? undefined : { name: label }
+      )
       if (result?.error) {
         // User dismissed the passkey prompt — not an error state
         if ('code' in result.error && result.error.code === 'REGISTRATION_CANCELLED') return

+ 2 - 58
src/lib/known-devices.ts

@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto'
 import { db } from '@/lib/db'
 import { type AccountMail, type AccountMailRow, sendAccountMail } from '@/lib/account-mail'
 import { DEVICE_COOKIE, readDeviceCookie } from '@/lib/device-cookie'
+import { describeUserAgent } from '@/lib/user-agent-label'
+export { classifyUserAgent, describeUserAgent, type DeviceKind } from '@/lib/user-agent-label'
 
 /**
  * What better-auth hands a database hook: the endpoint context of the request
@@ -22,64 +24,6 @@ export interface DeviceSighting {
   label: string
 }
 
-/**
- * "Chrome on Windows", "Safari on iPhone": enough for a person to recognise
- * their own device in a mail or a list, from a user agent string. Not a
- * fingerprint; the cookie does that.
- */
-export function describeUserAgent(userAgent: string | null | undefined): string {
-  const ua = userAgent ?? ''
-  if (!ua) return 'Unknown device'
-  if (/Torqvoice|Expo|okhttp|Dart|CFNetwork/i.test(ua) && !/Mozilla/i.test(ua)) {
-    return 'Torqvoice technician app'
-  }
-  const os = /iPhone/.test(ua)
-    ? 'iPhone'
-    : /iPad/.test(ua)
-      ? 'iPad'
-      : /Android/.test(ua)
-        ? 'Android'
-        : /Windows/.test(ua)
-          ? 'Windows'
-          : /Mac OS X|Macintosh/.test(ua)
-            ? 'Mac'
-            : /CrOS/.test(ua)
-              ? 'ChromeOS'
-              : /Linux/.test(ua)
-                ? 'Linux'
-                : null
-  const browser = /Edg\//.test(ua)
-    ? 'Edge'
-    : /OPR\/|Opera/.test(ua)
-      ? 'Opera'
-      : /SamsungBrowser/.test(ua)
-        ? 'Samsung Internet'
-        : /Firefox\//.test(ua)
-          ? 'Firefox'
-          : /Chrome\/|CriOS\//.test(ua)
-            ? 'Chrome'
-            : /Safari\//.test(ua)
-              ? 'Safari'
-              : null
-  if (browser && os) return `${browser} on ${os}`
-  return browser ?? os ?? 'Unknown device'
-}
-
-export type DeviceKind = 'phone' | 'tablet' | 'desktop' | 'app' | 'unknown'
-
-/** Phone, tablet or computer: which picture to draw beside a session. */
-export function classifyUserAgent(userAgent: string | null | undefined): DeviceKind {
-  const ua = userAgent ?? ''
-  if (!ua) return 'unknown'
-  if (/Torqvoice|Expo|okhttp|Dart|CFNetwork/i.test(ua) && !/Mozilla/i.test(ua)) return 'app'
-  if (/iPad|Tablet|PlayBook|Silk/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua))) {
-    return 'tablet'
-  }
-  if (/iPhone|iPod|Android|Mobile|Windows Phone/i.test(ua)) return 'phone'
-  if (/Windows|Macintosh|Mac OS X|CrOS|Linux|X11/i.test(ua)) return 'desktop'
-  return 'unknown'
-}
-
 function useSecureCookies(): boolean {
   return (process.env.NEXT_PUBLIC_APP_URL ?? '').startsWith('https://')
 }

+ 63 - 0
src/lib/user-agent-label.ts

@@ -0,0 +1,63 @@
+/**
+ * Plain-words names for a browser or phone, from its user agent. Pure, so the
+ * signed-in devices list (server) and a new passkey's name (browser) say the
+ * same thing about the same device.
+ */
+
+/**
+ * "Chrome on Windows", "Safari on iPhone": enough for a person to recognise
+ * their own device in a mail or a list, from a user agent string. Not a
+ * fingerprint; the cookie does that.
+ */
+export function describeUserAgent(userAgent: string | null | undefined): string {
+  const ua = userAgent ?? ''
+  if (!ua) return 'Unknown device'
+  if (/Torqvoice|Expo|okhttp|Dart|CFNetwork/i.test(ua) && !/Mozilla/i.test(ua)) {
+    return 'Torqvoice technician app'
+  }
+  const os = /iPhone/.test(ua)
+    ? 'iPhone'
+    : /iPad/.test(ua)
+      ? 'iPad'
+      : /Android/.test(ua)
+        ? 'Android'
+        : /Windows/.test(ua)
+          ? 'Windows'
+          : /Mac OS X|Macintosh/.test(ua)
+            ? 'Mac'
+            : /CrOS/.test(ua)
+              ? 'ChromeOS'
+              : /Linux/.test(ua)
+                ? 'Linux'
+                : null
+  const browser = /Edg\//.test(ua)
+    ? 'Edge'
+    : /OPR\/|Opera/.test(ua)
+      ? 'Opera'
+      : /SamsungBrowser/.test(ua)
+        ? 'Samsung Internet'
+        : /Firefox\//.test(ua)
+          ? 'Firefox'
+          : /Chrome\/|CriOS\//.test(ua)
+            ? 'Chrome'
+            : /Safari\//.test(ua)
+              ? 'Safari'
+              : null
+  if (browser && os) return `${browser} on ${os}`
+  return browser ?? os ?? 'Unknown device'
+}
+
+export type DeviceKind = 'phone' | 'tablet' | 'desktop' | 'app' | 'unknown'
+
+/** Phone, tablet or computer: which picture to draw beside a session. */
+export function classifyUserAgent(userAgent: string | null | undefined): DeviceKind {
+  const ua = userAgent ?? ''
+  if (!ua) return 'unknown'
+  if (/Torqvoice|Expo|okhttp|Dart|CFNetwork/i.test(ua) && !/Mozilla/i.test(ua)) return 'app'
+  if (/iPad|Tablet|PlayBook|Silk/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua))) {
+    return 'tablet'
+  }
+  if (/iPhone|iPod|Android|Mobile|Windows Phone/i.test(ua)) return 'phone'
+  if (/Windows|Macintosh|Mac OS X|CrOS|Linux|X11/i.test(ua)) return 'desktop'
+  return 'unknown'
+}