diff --git a/apps/platform/trpc/routers/userRouter/securityRouter.ts b/apps/platform/trpc/routers/userRouter/securityRouter.ts index 235dae2d..0b0dcaa8 100644 --- a/apps/platform/trpc/routers/userRouter/securityRouter.ts +++ b/apps/platform/trpc/routers/userRouter/securityRouter.ts @@ -763,7 +763,10 @@ export const securityRouter = router({ .input( z.object({ verificationToken: zodSchemas.nanoIdToken(), - authenticatorType: z.enum(['platform', 'cross-platform']) + /** + * @deprecated Let the browser decide + */ + authenticatorType: z.enum(['platform', 'cross-platform']).optional() }) ) .query(async ({ ctx, input }) => { @@ -806,8 +809,7 @@ export const securityRouter = router({ const passkeyOptions = await usePasskeys.generateRegistrationOptions({ userDisplayName: accountData.username, username: accountData.username, - accountPublicId: accountData.publicId, - authenticatorAttachment: input.authenticatorType + accountPublicId: accountData.publicId }); return { options: passkeyOptions }; }), diff --git a/apps/web/src/app/[orgShortCode]/settings/user/security/_components/delete-modals.tsx b/apps/web/src/app/[orgShortCode]/settings/user/security/_components/delete-modals.tsx new file mode 100644 index 00000000..98ad22d7 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/user/security/_components/delete-modals.tsx @@ -0,0 +1,59 @@ +import { type ModalComponent } from '@/src/hooks/use-awaitable-modal'; +import { Button, Dialog } from '@radix-ui/themes'; +import { type TypeId } from '@u22n/utils'; +import { api } from '@/src/lib/trpc'; + +export function DeletePasskeyModal({ + open, + publicId, + name, + verificationToken, + onClose, + onResolve +}: ModalComponent<{ + publicId: TypeId<'accountPasskey'>; + name: string; + verificationToken: string; +}>) { + const { + mutateAsync: deletePasskey, + isLoading: deletePasskeyLoading, + error + } = api.account.security.deletePasskey.useMutation(); + + return ( + + + Delete Passkey + + This action is irreversible. You will not be able to recover this + passkey. Are you sure you want to delete {name}? + +
{error?.message}
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/[orgShortCode]/settings/user/security/_components/passkey-modals.tsx b/apps/web/src/app/[orgShortCode]/settings/user/security/_components/passkey-modals.tsx new file mode 100644 index 00000000..a3b73ff3 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/settings/user/security/_components/passkey-modals.tsx @@ -0,0 +1,48 @@ +import { type ModalComponent } from '@/src/hooks/use-awaitable-modal'; +import { Dialog, TextField, Button } from '@radix-ui/themes'; +import { useState } from 'react'; + +export function PasskeyNameModal({ + open, + onClose, + onResolve +}: ModalComponent, string>) { + const [name, setName] = useState('Passkey'); + return ( + + + + Name your new passkey + + + This will help you identify your passkey in the future. Keep it simple + like Android Phone, Apple ID, Windows Hello, YubiKey etc. + +
+ { + setName(e.target.value); + }} + /> +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx b/apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx index 5ef7b7bf..33dfde05 100644 --- a/apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx +++ b/apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx @@ -1,9 +1,18 @@ 'use client'; -import { Flex, Heading, Spinner, Text, Switch, Button } from '@radix-ui/themes'; +import { + Flex, + Heading, + Spinner, + Text, + Switch, + Button, + IconButton +} from '@radix-ui/themes'; import { useEffect, useState } from 'react'; import { api } from '@/src/lib/trpc'; import { VerificationModal } from './_components/verification-modal'; +import { DeletePasskeyModal } from './_components/delete-modals'; import { PasswordModal, TOTPModal, @@ -11,6 +20,11 @@ import { } from './_components/reset-modals'; import useAwaitableModal from '@/src/hooks/use-awaitable-modal'; import { toast } from 'sonner'; +import { Trash } from 'lucide-react'; +import { format } from 'date-fns'; +import useLoading from '@/src/hooks/use-loading'; +import { startRegistration } from '@simplewebauthn/browser'; +// import { PasskeyNameModal } from './_components/passkey-modals'; export default function Page() { const { @@ -59,6 +73,55 @@ export default function Page() { } ); + const [DeletePasskeyModalRoot, openDeletePasskeyModal] = useAwaitableModal( + DeletePasskeyModal, + { + publicId: 'ap_', + name: '', + verificationToken: '' + } + ); + + // const [PasskeyNameModalRoot, openPasskeyNameModal] = useAwaitableModal( + // PasskeyNameModal, + // {} + // ); + + const fetchPasskeyChallengeApi = + api.useUtils().account.security.generateNewPasskeyChallenge; + const { mutateAsync: addNewPasskey } = + api.account.security.addNewPasskey.useMutation({ + onSuccess: () => { + void refreshSecurityData(); + }, + onError: (err) => { + toast.error('Something went wrong while adding new passkey', { + description: err.message + }); + } + }); + + const { loading: passkeyAddLoading, run: addPasskey } = useLoading( + async () => { + const token = await waitForVerification(); + if (!token) return; + const challenge = await fetchPasskeyChallengeApi.fetch({ + verificationToken: token + }); + const response = await startRegistration(challenge.options); + + // Need to have a separate endpoint for rename + // const passkeyName = await openPasskeyNameModal().catch(() => null); + // if (!passkeyName) return; + + await addNewPasskey({ + verificationToken: token, + // nickname: passkeyName, + registrationResponseRaw: response + }); + } + ); + async function waitForVerification() { if (!initData) throw new Error('No init data'); if (verificationToken) return verificationToken; @@ -83,6 +146,8 @@ export default function Page() { }); const canDisableLegacySecurity = (initData?.passkeys.length ?? 0) > 0; + const canDeletePasskey = + (initData?.passkeys.length ?? 0) > 1 || isPassword2FaEnabled; return ( { const token = await waitForVerification(); @@ -200,6 +265,51 @@ export default function Page() { )} +
+ Passkeys +
+ {initData?.passkeys.map((passkey) => ( +
+
+ {passkey.nickname} + + {format(passkey.createdAt, ' HH:mm, do MMM yyyy')} + +
+
+ { + const token = await waitForVerification(); + if (!token) return; + await openDeletePasskeyModal({ + publicId: passkey.publicId, + name: passkey.nickname, + verificationToken: verificationToken ?? token + }) + .then(() => refreshSecurityData()) + .catch(() => null); + }}> + + +
+
+ ))} + {initData?.passkeys.length === 0 && ( +
No passkeys found
+ )} +
+ +
)} @@ -207,6 +317,8 @@ export default function Page() { + + {/* */}
); }