Skip to content

Commit 80f3317

Browse files
committed
feat: Passkeys section to show/add/delete passkeys
1 parent cba3087 commit 80f3317

File tree

4 files changed

+226
-5
lines changed

4 files changed

+226
-5
lines changed

apps/platform/trpc/routers/userRouter/securityRouter.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,10 @@ export const securityRouter = router({
763763
.input(
764764
z.object({
765765
verificationToken: zodSchemas.nanoIdToken(),
766-
authenticatorType: z.enum(['platform', 'cross-platform'])
766+
/**
767+
* @deprecated Let the browser decide
768+
*/
769+
authenticatorType: z.enum(['platform', 'cross-platform']).optional()
767770
})
768771
)
769772
.query(async ({ ctx, input }) => {
@@ -806,8 +809,7 @@ export const securityRouter = router({
806809
const passkeyOptions = await usePasskeys.generateRegistrationOptions({
807810
userDisplayName: accountData.username,
808811
username: accountData.username,
809-
accountPublicId: accountData.publicId,
810-
authenticatorAttachment: input.authenticatorType
812+
accountPublicId: accountData.publicId
811813
});
812814
return { options: passkeyOptions };
813815
}),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { type ModalComponent } from '@/src/hooks/use-awaitable-modal';
2+
import { Button, Dialog } from '@radix-ui/themes';
3+
import { type TypeId } from '@u22n/utils';
4+
import { api } from '@/src/lib/trpc';
5+
6+
export function DeletePasskeyModal({
7+
open,
8+
publicId,
9+
name,
10+
verificationToken,
11+
onClose,
12+
onResolve
13+
}: ModalComponent<{
14+
publicId: TypeId<'accountPasskey'>;
15+
name: string;
16+
verificationToken: string;
17+
}>) {
18+
const {
19+
mutateAsync: deletePasskey,
20+
isLoading: deletePasskeyLoading,
21+
error
22+
} = api.account.security.deletePasskey.useMutation();
23+
24+
return (
25+
<Dialog.Root open={open}>
26+
<Dialog.Content className="w-full max-w-96 p-4">
27+
<Dialog.Title className="mx-auto w-fit">Delete Passkey</Dialog.Title>
28+
<Dialog.Description className="mx-auto my-6 flex w-fit text-balance p-2 text-center text-sm font-bold">
29+
This action is irreversible. You will not be able to recover this
30+
passkey. Are you sure you want to delete {name}?
31+
</Dialog.Description>
32+
<div className="text-red-10 w-full">{error?.message}</div>
33+
<div className="flex flex-col gap-3">
34+
<Button
35+
color="red"
36+
className="w-full"
37+
loading={deletePasskeyLoading}
38+
onClick={async () => {
39+
await deletePasskey({
40+
passkeyPublicId: publicId,
41+
verificationToken
42+
});
43+
onResolve(null);
44+
}}>
45+
Delete
46+
</Button>
47+
<Button
48+
disabled={deletePasskeyLoading}
49+
onClick={() => onClose()}
50+
className="w-full"
51+
variant="soft"
52+
color="gray">
53+
Cancel
54+
</Button>
55+
</div>
56+
</Dialog.Content>
57+
</Dialog.Root>
58+
);
59+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { type ModalComponent } from '@/src/hooks/use-awaitable-modal';
2+
import { Dialog, TextField, Button } from '@radix-ui/themes';
3+
import { useState } from 'react';
4+
5+
export function PasskeyNameModal({
6+
open,
7+
onClose,
8+
onResolve
9+
}: ModalComponent<NonNullable<unknown>, string>) {
10+
const [name, setName] = useState('Passkey');
11+
return (
12+
<Dialog.Root open={open}>
13+
<Dialog.Content className="w-full max-w-96 p-4">
14+
<Dialog.Title className="mx-auto w-fit">
15+
Name your new passkey
16+
</Dialog.Title>
17+
<Dialog.Description className="mx-auto flex w-fit text-balance p-2 text-center text-sm font-bold">
18+
This will help you identify your passkey in the future. Keep it simple
19+
like Android Phone, Apple ID, Windows Hello, YubiKey etc.
20+
</Dialog.Description>
21+
<div className="my-6 flex flex-col gap-2">
22+
<TextField.Root
23+
defaultValue={'Passkey'}
24+
onChange={(e) => {
25+
setName(e.target.value);
26+
}}
27+
/>
28+
</div>
29+
<div className="flex flex-col gap-3">
30+
<Button
31+
className="w-full"
32+
onClick={async () => {
33+
onResolve(name);
34+
}}>
35+
Save
36+
</Button>
37+
<Button
38+
onClick={() => onClose()}
39+
className="w-full"
40+
variant="soft"
41+
color="gray">
42+
Cancel
43+
</Button>
44+
</div>
45+
</Dialog.Content>
46+
</Dialog.Root>
47+
);
48+
}

apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
'use client';
22

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';
412
import { useEffect, useState } from 'react';
513
import { api } from '@/src/lib/trpc';
614
import { VerificationModal } from './_components/verification-modal';
15+
import { DeletePasskeyModal } from './_components/delete-modals';
716
import {
817
PasswordModal,
918
TOTPModal,
1019
RecoveryCodeModal
1120
} from './_components/reset-modals';
1221
import useAwaitableModal from '@/src/hooks/use-awaitable-modal';
1322
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';
1428

1529
export default function Page() {
1630
const {
@@ -59,6 +73,55 @@ export default function Page() {
5973
}
6074
);
6175

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+
62125
async function waitForVerification() {
63126
if (!initData) throw new Error('No init data');
64127
if (verificationToken) return verificationToken;
@@ -83,6 +146,8 @@ export default function Page() {
83146
});
84147

85148
const canDisableLegacySecurity = (initData?.passkeys.length ?? 0) > 0;
149+
const canDeletePasskey =
150+
(initData?.passkeys.length ?? 0) > 1 || isPassword2FaEnabled;
86151

87152
return (
88153
<Flex
@@ -121,7 +186,7 @@ export default function Page() {
121186
checked={isPassword2FaEnabled}
122187
disabled={
123188
isDisablingLegacySecurity ||
124-
!(isPassword2FaEnabled && canDisableLegacySecurity)
189+
(isPassword2FaEnabled && !canDisableLegacySecurity)
125190
}
126191
onCheckedChange={async () => {
127192
const token = await waitForVerification();
@@ -200,13 +265,60 @@ export default function Page() {
200265
</Button>
201266
</div>
202267
)}
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>
203313
</div>
204314
)}
205315

206316
<VerificationModalRoot />
207317
<PasswordModalRoot />
208318
<TOTPModalRoot />
209319
<RecoveryModalRoot />
320+
<DeletePasskeyModalRoot />
321+
{/* <PasskeyNameModalRoot /> */}
210322
</Flex>
211323
);
212324
}

0 commit comments

Comments
 (0)