1
1
'use client' ;
2
2
3
- import { Flex , Heading , Spinner , Text , Switch , Button } from '@radix-ui/themes' ;
3
+ import {
4
+ Flex ,
5
+ Heading ,
6
+ Spinner ,
7
+ Text ,
8
+ Switch ,
9
+ Button ,
10
+ IconButton
11
+ } from '@radix-ui/themes' ;
4
12
import { useEffect , useState } from 'react' ;
5
13
import { api } from '@/src/lib/trpc' ;
6
14
import { VerificationModal } from './_components/verification-modal' ;
15
+ import { DeletePasskeyModal } from './_components/delete-modals' ;
7
16
import {
8
17
PasswordModal ,
9
18
TOTPModal ,
10
19
RecoveryCodeModal
11
20
} from './_components/reset-modals' ;
12
21
import useAwaitableModal from '@/src/hooks/use-awaitable-modal' ;
13
22
import { toast } from 'sonner' ;
23
+ import { Trash } from 'lucide-react' ;
24
+ import { format } from 'date-fns' ;
25
+ import useLoading from '@/src/hooks/use-loading' ;
26
+ import { startRegistration } from '@simplewebauthn/browser' ;
27
+ // import { PasskeyNameModal } from './_components/passkey-modals';
14
28
15
29
export default function Page ( ) {
16
30
const {
@@ -59,6 +73,55 @@ export default function Page() {
59
73
}
60
74
) ;
61
75
76
+ const [ DeletePasskeyModalRoot , openDeletePasskeyModal ] = useAwaitableModal (
77
+ DeletePasskeyModal ,
78
+ {
79
+ publicId : 'ap_' ,
80
+ name : '' ,
81
+ verificationToken : ''
82
+ }
83
+ ) ;
84
+
85
+ // const [PasskeyNameModalRoot, openPasskeyNameModal] = useAwaitableModal(
86
+ // PasskeyNameModal,
87
+ // {}
88
+ // );
89
+
90
+ const fetchPasskeyChallengeApi =
91
+ api . useUtils ( ) . account . security . generateNewPasskeyChallenge ;
92
+ const { mutateAsync : addNewPasskey } =
93
+ api . account . security . addNewPasskey . useMutation ( {
94
+ onSuccess : ( ) => {
95
+ void refreshSecurityData ( ) ;
96
+ } ,
97
+ onError : ( err ) => {
98
+ toast . error ( 'Something went wrong while adding new passkey' , {
99
+ description : err . message
100
+ } ) ;
101
+ }
102
+ } ) ;
103
+
104
+ const { loading : passkeyAddLoading , run : addPasskey } = useLoading (
105
+ async ( ) => {
106
+ const token = await waitForVerification ( ) ;
107
+ if ( ! token ) return ;
108
+ const challenge = await fetchPasskeyChallengeApi . fetch ( {
109
+ verificationToken : token
110
+ } ) ;
111
+ const response = await startRegistration ( challenge . options ) ;
112
+
113
+ // Need to have a separate endpoint for rename
114
+ // const passkeyName = await openPasskeyNameModal().catch(() => null);
115
+ // if (!passkeyName) return;
116
+
117
+ await addNewPasskey ( {
118
+ verificationToken : token ,
119
+ // nickname: passkeyName,
120
+ registrationResponseRaw : response
121
+ } ) ;
122
+ }
123
+ ) ;
124
+
62
125
async function waitForVerification ( ) {
63
126
if ( ! initData ) throw new Error ( 'No init data' ) ;
64
127
if ( verificationToken ) return verificationToken ;
@@ -83,6 +146,8 @@ export default function Page() {
83
146
} ) ;
84
147
85
148
const canDisableLegacySecurity = ( initData ?. passkeys . length ?? 0 ) > 0 ;
149
+ const canDeletePasskey =
150
+ ( initData ?. passkeys . length ?? 0 ) > 1 || isPassword2FaEnabled ;
86
151
87
152
return (
88
153
< Flex
@@ -121,7 +186,7 @@ export default function Page() {
121
186
checked = { isPassword2FaEnabled }
122
187
disabled = {
123
188
isDisablingLegacySecurity ||
124
- ! ( isPassword2FaEnabled && canDisableLegacySecurity )
189
+ ( isPassword2FaEnabled && ! canDisableLegacySecurity )
125
190
}
126
191
onCheckedChange = { async ( ) => {
127
192
const token = await waitForVerification ( ) ;
@@ -200,13 +265,60 @@ export default function Page() {
200
265
</ Button >
201
266
</ div >
202
267
) }
268
+ < div className = "flex flex-col gap-3" >
269
+ < span className = "text-lg font-bold" > Passkeys</ span >
270
+ < div className = "flex flex-wrap gap-2" >
271
+ { initData ?. passkeys . map ( ( passkey ) => (
272
+ < div
273
+ key = { passkey . publicId }
274
+ className = "bg-muted flex items-center justify-center gap-2 rounded border px-2 py-1" >
275
+ < div className = "flex flex-col" >
276
+ < span > { passkey . nickname } </ span >
277
+ < span className = "text-muted-foreground text-xs" >
278
+ { format ( passkey . createdAt , ' HH:mm, do MMM yyyy' ) }
279
+ </ span >
280
+ </ div >
281
+ < div >
282
+ < IconButton
283
+ size = "2"
284
+ variant = "soft"
285
+ disabled = { ! canDeletePasskey }
286
+ onClick = { async ( ) => {
287
+ const token = await waitForVerification ( ) ;
288
+ if ( ! token ) return ;
289
+ await openDeletePasskeyModal ( {
290
+ publicId : passkey . publicId ,
291
+ name : passkey . nickname ,
292
+ verificationToken : verificationToken ?? token
293
+ } )
294
+ . then ( ( ) => refreshSecurityData ( ) )
295
+ . catch ( ( ) => null ) ;
296
+ } } >
297
+ < Trash size = { 16 } />
298
+ </ IconButton >
299
+ </ div >
300
+ </ div >
301
+ ) ) }
302
+ { initData ?. passkeys . length === 0 && (
303
+ < div className = "text-muted-foreground" > No passkeys found</ div >
304
+ ) }
305
+ </ div >
306
+ < Button
307
+ className = "w-fit"
308
+ loading = { passkeyAddLoading }
309
+ onClick = { ( ) => addPasskey ( { clearError : true , clearData : true } ) } >
310
+ Add New Passkey
311
+ </ Button >
312
+ </ div >
203
313
</ div >
204
314
) }
205
315
206
316
< VerificationModalRoot />
207
317
< PasswordModalRoot />
208
318
< TOTPModalRoot />
209
319
< RecoveryModalRoot />
320
+ < DeletePasskeyModalRoot />
321
+ { /* <PasskeyNameModalRoot /> */ }
210
322
</ Flex >
211
323
) ;
212
324
}
0 commit comments