Repository: safeinsights/management-app
Pull request #224: Adds feature for regenerating a reviewer key Otter 182
Author: login: stetradis
Created: 2025-05-22T15:33:21Z | Merged: 2025-05-22T18:12:06Z

- Adds feature for regenerating a reviewer key


Diff:
diff --git a/src/app/account/keys/generate-keys.tsx b/src/app/account/keys/generate-keys.tsx
index 342e91934..7abf7eb3a 100644
--- a/src/app/account/keys/generate-keys.tsx
+++ b/src/app/account/keys/generate-keys.tsx
@@ -20,7 +20,7 @@ import { generateKeyPair } from 'si-encryption/util/keypair'
 import { useDisclosure } from '@mantine/hooks'
 import { AppModal } from '@/components/modal'
 import { Check } from '@phosphor-icons/react/dist/ssr'
-import { setReviewerPublicKeyAction } from '@/server/actions/user-keys.actions'
+import { setReviewerPublicKeyAction, updateReviewerPublicKeyAction } from '@/server/actions/user-keys.actions'
 import { useRouter } from 'next/navigation'
 import { reportMutationError } from '@/components/errors'
 
@@ -30,7 +30,11 @@ interface Keys {
     fingerprint: string
 }
 
-export const GenerateKeys: FC = () => {
+type GenerateKeysProps = {
+    isRegenerating?: boolean
+}
+
+export const GenerateKeys: FC<GenerateKeysProps> = ({ isRegenerating = false }) => {
     const theme = useMantineTheme()
     const [keys, setKeys] = useState<Keys>()
     const [confirmationOpened, { open: openConfirmKeyCopied, close: closeConfirmKeyCopied }] = useDisclosure(false)
@@ -115,21 +119,39 @@ export const GenerateKeys: FC = () => {
                         )}
                     </Stack>
                 </Paper>
-                <ConfirmationModal onClose={closeConfirmKeyCopied} isOpen={confirmationOpened} keys={keys} />
+                <ConfirmationModal
+                    onClose={closeConfirmKeyCopied}
+                    isOpen={confirmationOpened}
+                    keys={keys}
+                    isRegenerating={isRegenerating}
+                />
             </Container>
         )
     }
 }
 
-const ConfirmationModal: FC<{ onClose: () => void; isOpen: boolean; keys: Keys }> = ({ onClose, isOpen, keys }) => {
+const ConfirmationModal: FC<{ onClose: () => void; isOpen: boolean; keys: Keys; isRegenerating: boolean }> = ({
+    onClose,
+    isOpen,
+    keys,
+    isRegenerating,
+}) => {
     const router = useRouter()
 
     const { mutate: saveReviewerKey, isPending: isSavingKey } = useMutation({
-        mutationFn: () =>
-            setReviewerPublicKeyAction({
-                publicKey: keys.binaryPublicKey,
-                fingerprint: keys.fingerprint,
-            }),
+        mutationFn: () => {
+            if (isRegenerating) {
+                return updateReviewerPublicKeyAction({
+                    publicKey: keys.binaryPublicKey,
+                    fingerprint: keys.fingerprint,
+                })
+            } else {
+                return setReviewerPublicKeyAction({
+                    publicKey: keys.binaryPublicKey,
+                    fingerprint: keys.fingerprint,
+                })
+            }
+        },
         onError: reportMutationError,
         onSuccess() {
             router.push('/')
diff --git a/src/app/account/keys/layout.tsx b/src/app/account/keys/layout.tsx
new file mode 100644
index 000000000..a9a16f9ba
--- /dev/null
+++ b/src/app/account/keys/layout.tsx
@@ -0,0 +1,25 @@
+import { getReviewerPublicKeyAction } from '@/server/actions/user-keys.actions'
+import { UserLayout } from '@/components/layout/user-layout'
+import { AnonLayout } from '@/components/layout/anon-layout'
+import { ReactNode } from 'react'
+import { RegenerateKeys } from './regenerate-keys'
+
+export default async function ReviewerKeysPageLayout({
+    children,
+}: Readonly<{
+    children: ReactNode
+}>) {
+    const publicKey = await getReviewerPublicKeyAction()
+
+    if (publicKey) {
+        // User has a key - show UserLayout
+        return (
+            <UserLayout>
+                <RegenerateKeys />
+            </UserLayout>
+        )
+    } else {
+        // No key, first-time generation - show AnonLayout
+        return <AnonLayout>{children}</AnonLayout>
+    }
+}
diff --git a/src/app/account/keys/regenerate-keys.test.tsx b/src/app/account/keys/regenerate-keys.test.tsx
new file mode 100644
index 000000000..9de95a69b
--- /dev/null
+++ b/src/app/account/keys/regenerate-keys.test.tsx
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mockClerkSession, renderWithProviders } from '@/tests/unit.helpers'
+import { RegenerateKeys } from './regenerate-keys'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { UseUserReturn } from '@clerk/types'
+import { useUser } from '@clerk/nextjs'
+import { generateKeyPair } from 'si-encryption/util/keypair'
+import { updateReviewerPublicKeyAction } from '@/server/actions/user-keys.actions'
+
+vi.mock('si-encryption/util/keypair', () => ({
+    generateKeyPair: vi.fn(),
+}))
+
+vi.mock('@/server/actions/user-keys.actions', () => ({
+    updateReviewerPublicKeyAction: vi.fn(),
+}))
+
+describe('Reviewer keypair regeneration', () => {
+    it('should regenerate a reviewer key pair and update public key', async () => {
+        vi.mocked(useUser).mockReturnValue({
+            user: { firstName: 'Tester' },
+        } as UseUserReturn)
+
+        mockClerkSession({
+            clerkUserId: 'user-id',
+            org_slug: 'dev',
+        })
+
+        const mockKeys = {
+            publicKeyString: 'mockPublicKey',
+            privateKeyString: 'mockPrivateKey',
+            fingerprint: 'mockFingerprint',
+            exportedPublicKey: new ArrayBuffer(8),
+        }
+        vi.mocked(generateKeyPair).mockResolvedValue(mockKeys as never)
+
+        renderWithProviders(<RegenerateKeys />)
+
+        await waitFor(() => {
+            expect(screen.getByText('Reviewer key', { selector: 'h1' })).toBeDefined()
+        })
+
+        // Open the modal requesting regeneration
+        fireEvent.click(screen.getByRole('button', { name: /lost key\? generate a new one/i }))
+
+        await waitFor(() => {
+            expect(screen.getByText(/confirm key reset/i)).toBeDefined()
+        })
+        fireEvent.click(screen.getByRole('button', { name: /generate new key/i }))
+
+        // GenerateKeys component should now be rendered
+        await waitFor(() => {
+            expect(vi.mocked(generateKeyPair)).toHaveBeenCalled()
+            expect(screen.getByText('Reviewer key', { selector: 'h1' })).toBeDefined()
+        })
+
+        // mock copy key
+        const copyKeyButton = screen.getByRole('button', { name: /copy key/i })
+        fireEvent.click(copyKeyButton)
+        await waitFor(() => {
+            expect(screen.getByText('Copied!')).toBeDefined()
+        })
+
+        // Navigate to confirmation modal
+        fireEvent.click(screen.getByRole('button', { name: /go to dashboard/i }))
+
+        await waitFor(() => {
+            expect(screen.getByText(/make sure you have securely saved your reviewer key\./i)).toBeDefined()
+        })
+
+        // Final confirmation which triggers updateReviewerPublicKeyAction
+        fireEvent.click(screen.getByRole('button', { name: /yes, go to dashboard/i }))
+
+        await waitFor(() => {
+            expect(updateReviewerPublicKeyAction).toHaveBeenCalledWith(
+                expect.objectContaining({
+                    publicKey: mockKeys.exportedPublicKey,
+                    fingerprint: mockKeys.fingerprint,
+                }),
+            )
+        })
+    })
+})
diff --git a/src/app/account/keys/regenerate-keys.tsx b/src/app/account/keys/regenerate-keys.tsx
new file mode 100644
index 000000000..2cd84d9cd
--- /dev/null
+++ b/src/app/account/keys/regenerate-keys.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { Button, Stack, Text, Group, Container, Title, Paper, Divider } from '@mantine/core'
+import { AppModal } from '@/components/modal'
+import { FC, useState } from 'react'
+import { useDisclosure } from '@mantine/hooks'
+import { GenerateKeys } from './generate-keys'
+
+export const RegenerateKeys: FC = () => {
+    const [isModalOpen, { open: openModal, close: closeModal }] = useDisclosure(false)
+    const [displayGenerateKeys, setDisplayGenerateKeys] = useState(false)
+
+    const handleConfirmAndProceed = () => {
+        closeModal()
+        setDisplayGenerateKeys(true)
+    }
+
+    if (displayGenerateKeys) {
+        return <GenerateKeys isRegenerating={true} />
+    }
+
+    return (
+        <Container>
+            <Title my="xxl">Reviewer key</Title>
+            <Paper shadow="xs" p="xl">
+                <Stack>
+                    <Title size="xl">Reviewer key details</Title>
+                    <Divider c="charcoal.1" />
+                    <Stack>
+                        <Text size="sm" fw={600}>
+                            Reviewer key already exists
+                        </Text>
+                        <Text size="md" mb="xs">
+                            You have already generated a reviewer key. For security reasons, SafeInsights does not store
+                            or display it again.
+                        </Text>
+                        <Text size="sm" fw={600}>
+                            Lost key?
+                        </Text>
+
+                        <Text size="md">If you have lost your reviewer key, you will need to generate a new one.</Text>
+                        <Text size="md" c="red.9">
+                            Note: If you generate a new reviewer key, you will no longer have access to any study
+                            results associated with your previous key. This action cannot be undone.
+                        </Text>
+                    </Stack>
+                    <Group>
+                        <Button onClick={() => openModal()}>Lost key? Generate a new one</Button>
+                    </Group>
+                </Stack>
+            </Paper>
+            <GenerateNewKeyModal
+                onClose={closeModal}
+                isOpen={isModalOpen}
+                onConfirmAndClose={handleConfirmAndProceed}
+            />
+        </Container>
+    )
+}
+
+const GenerateNewKeyModal: FC<{
+    onClose: () => void
+    isOpen: boolean
+    onConfirmAndClose: () => void
+}> = ({ onClose, isOpen, onConfirmAndClose }) => {
+    return (
+        <AppModal isOpen={isOpen} onClose={onClose} title="Confirm key reset">
+            <Stack>
+                <Text size="md">
+                    Generating a new reviewer key will permanently remove access to study results tied to your old key.
+                </Text>
+                <Text size="md" mb="md">
+                    This action cannot be undone.
+                </Text>
+                <Group>
+                    <Button variant="outline" onClick={onClose}>
+                        Cancel
+                    </Button>
+                    <Button onClick={onConfirmAndClose}>Generate new key</Button>
+                </Group>
+            </Stack>
+        </AppModal>
+    )
+}
diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx
index 8cf8aa3c0..c03a7f2b4 100644
--- a/src/app/account/layout.tsx
+++ b/src/app/account/layout.tsx
@@ -2,7 +2,5 @@ import { AnonLayout } from '@/components/layout/anon-layout'
 import { type ReactNode } from 'react'
 
 export default async function AccountLayout({ children }: Readonly<{ children: ReactNode }>) {
-    const key = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''
-
-    return <AnonLayout clerkPublishableKey={key}>{children}</AnonLayout>
+    return <AnonLayout>{children}</AnonLayout>
 }
diff --git a/src/components/layout/anon-layout.test.tsx b/src/components/layout/anon-layout-shell.test.tsx
similarity index 73%
rename from src/components/layout/anon-layout.test.tsx
rename to src/components/layout/anon-layout-shell.test.tsx
index da980a974..f7f876a1c 100644
--- a/src/components/layout/anon-layout.test.tsx
+++ b/src/components/layout/anon-layout-shell.test.tsx
@@ -1,14 +1,14 @@
 import { it } from 'vitest'
-import { AnonLayout } from './anon-layout'
+import { AnonLayoutShell } from './anon-layout-shell'
 import { render, screen } from '@testing-library/react'
 
 import { TestingProviders } from '@/tests/providers'
 
 it('renders without a user', async () => {
     render(
-        <AnonLayout clerkPublishableKey="1234">
+        <AnonLayoutShell>
             <div>hello world</div>
-        </AnonLayout>,
+        </AnonLayoutShell>,
         { wrapper: TestingProviders },
     )
 
diff --git a/src/components/layout/anon-layout-shell.tsx b/src/components/layout/anon-layout-shell.tsx
new file mode 100644
index 000000000..cd5757d8e
--- /dev/null
+++ b/src/components/layout/anon-layout-shell.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+import {
+    AppShell,
+    AppShellFooter,
+    AppShellMain,
+    AppShellNavbar,
+    AppShellSection,
+    Group,
+    Stack,
+    Text,
+    useMantineTheme,
+} from '@mantine/core'
+import { SafeInsightsLogo } from './si-logo'
+import { Notifications } from '@mantine/notifications'
+import '@mantine/notifications/styles.css'
+import { ReactNode } from 'react'
+
+type Props = {
+    children: ReactNode
+}
+
+export function AnonLayoutShell({ children }: Props) {
+    const theme = useMantineTheme()
+
+    return (
+        <AppShell footer={{ height: 60 }} navbar={{ width: 250, breakpoint: 'xs' }} padding="md">
+            <Notifications position="top-right" />
+            <AppShellNavbar bg={theme.colors.purple[8]}>
+                <Stack py="md">
+                    <AppShellSection>
+                        <SafeInsightsLogo />
+                    </AppShellSection>
+                </Stack>
+            </AppShellNavbar>
+            <AppShellMain bg="#F1F3F5">{children}</AppShellMain>
+            <AppShellFooter p="md" bg={theme.colors.purple[9]} bd="none">
+                <Group justify="left" c="white">
+                    <Text c="white">© 2025 - SafeInsights, Rice University</Text>
+                </Group>
+            </AppShellFooter>
+        </AppShell>
+    )
+}
diff --git a/src/components/layout/anon-layout.tsx b/src/components/layout/anon-layout.tsx
index 1c31745a6..f5b1e52ec 100644
--- a/src/components/layout/anon-layout.tsx
+++ b/src/components/layout/anon-layout.tsx
@@ -1,48 +1,16 @@
-'use client'
-
-import {
-    AppShell,
-    AppShellFooter,
-    AppShellMain,
-    AppShellNavbar,
-    AppShellSection,
-    Group,
-    Stack,
-    Text,
-    useMantineTheme,
-} from '@mantine/core'
-import { SafeInsightsLogo } from './si-logo'
-import { Notifications } from '@mantine/notifications'
-import '@mantine/notifications/styles.css'
-import { ReactNode } from 'react'
+import { AnonLayoutShell } from './anon-layout-shell'
 import { ClerkProvider } from '@clerk/nextjs'
 
 type Props = {
-    children: ReactNode
-    clerkPublishableKey: string
+    children: React.ReactNode
 }
 
-export function AnonLayout({ children, clerkPublishableKey }: Props) {
-    const theme = useMantineTheme()
+export function AnonLayout({ children }: Props) {
+    const clerkPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''
 
     return (
         <ClerkProvider publishableKey={clerkPublishableKey}>
-            <AppShell footer={{ height: 60 }} navbar={{ width: 250, breakpoint: 'xs' }} padding="md">
-                <Notifications position="top-right" />
-                <AppShellNavbar bg={theme.colors.purple[8]}>
-                    <Stack py="md">
-                        <AppShellSection>
-                            <SafeInsightsLogo />
-                        </AppShellSection>
-                    </Stack>
-                </AppShellNavbar>
-                <AppShellMain bg="#F1F3F5">{children}</AppShellMain>
-                <AppShellFooter p="md" bg={theme.colors.purple[9]} bd="none">
-                    <Group justify="left" c="white">
-                        <Text c="white">© 2025 - SafeInsights, Rice University</Text>
-                    </Group>
-                </AppShellFooter>
-            </AppShell>
+            <AnonLayoutShell>{children}</AnonLayoutShell>
         </ClerkProvider>
     )
 }
diff --git a/src/components/layout/navbar-profile-menu.tsx b/src/components/layout/navbar-profile-menu.tsx
index 7b5a85b5d..06702074d 100644
--- a/src/components/layout/navbar-profile-menu.tsx
+++ b/src/components/layout/navbar-profile-menu.tsx
@@ -2,31 +2,31 @@
 
 import { AppShellSection, Collapse, NavLink } from '@mantine/core'
 import { useDisclosure, useClickOutside, useHotkeys } from '@mantine/hooks'
-import { CaretRight, SignOut, User } from '@phosphor-icons/react/dist/ssr'
+import { CaretRight, SignOut, User, Lock } from '@phosphor-icons/react/dist/ssr'
 import { useClerk } from '@clerk/nextjs'
 import { UserAvatar } from '@/components/user-avatar'
 import { UserName } from '@/components/user-name'
 import { useRef } from 'react'
 import styles from './navbar-items.module.css'
+import { Protect } from '@/components/auth'
+import { useRouter } from 'next/navigation'
+import { AuthRole } from '@/lib/types'
 
 export function NavbarProfileMenu() {
     const { signOut, openUserProfile } = useClerk()
     const [opened, { toggle, close }] = useDisclosure()
-
+    const router = useRouter()
     const toggleButtonRef = useRef<HTMLAnchorElement>(null)
     const accountMenuItemRef = useRef<HTMLAnchorElement>(null)
-    // TODO: restore once reviewkey page is built
-    // const reviewerKeyMenuItemRef = useRef<HTMLAnchorElement>(null)
+    const reviewerKeyMenuItemRef = useRef<HTMLAnchorElement>(null)
     const signOutMenuItemRef = useRef<HTMLAnchorElement>(null)
 
     const menuRef = useClickOutside<HTMLDivElement>(() => opened && close())
 
     // Active refs and navigation helpers
-    const activeRefs = [
-        accountMenuItemRef.current,
-        // reviewerKeyMenuItemRef.current,
-        signOutMenuItemRef.current,
-    ].filter(Boolean) as HTMLAnchorElement[]
+    const activeRefs = [accountMenuItemRef.current, reviewerKeyMenuItemRef.current, signOutMenuItemRef.current].filter(
+        Boolean,
+    ) as HTMLAnchorElement[]
 
     const focusItem = (index: number) => activeRefs[index]?.focus()
 
@@ -99,20 +99,23 @@ export function NavbarProfileMenu() {
                     tabIndex={opened ? 0 : -1}
                     ref={accountMenuItemRef}
                 />
-                {/* TODO: restore once page is built*/}
-                {/* <NavLink
-                    label="Reviewer Key"
-                    leftSection={<Lock aria-hidden="true" />}
-                    onClick={() => {
-                        window.alert('404. Design under construction')
-                    }}
-                    c="white"
-                    className={styles.navLinkProfileHover}
-                    role="menuitem"
-                    aria-label="Reviewer Key" 
-                    tabIndex={opened ? 0 : -1}
-                    ref={reviewerKeyMenuItemRef}
-                /> */}
+
+                <Protect role={AuthRole.Reviewer}>
+                    <NavLink
+                        label="Reviewer Key"
+                        leftSection={<Lock aria-hidden="true" />}
+                        onClick={() => {
+                            router.push('/account/keys')
+                        }}
+                        c="white"
+                        className={styles.navLinkProfileHover}
+                        role="menuitem"
+                        aria-label="Reviewer Key"
+                        tabIndex={opened ? 0 : -1}
+                        ref={reviewerKeyMenuItemRef}
+                    />
+                </Protect>
+
                 <NavLink
                     label="Sign Out"
                     leftSection={<SignOut aria-hidden="true" />}
diff --git a/src/server/actions/user-keys.actions.ts b/src/server/actions/user-keys.actions.ts
index 671a1d1d4..0b2c6c09a 100644
--- a/src/server/actions/user-keys.actions.ts
+++ b/src/server/actions/user-keys.actions.ts
@@ -3,6 +3,7 @@
 import { db } from '@/database'
 import { z, userAction, getUserIdFromActionContext, ActionFailure } from './wrappers'
 import { getReviewerPublicKey } from '@/server/db/queries'
+import { onUserPublicKeyCreated, onUserPublicKeyUpdated } from '@/server/events'
 
 export const getReviewerFingerprintAction = userAction(async () => {
     const userId = await getUserIdFromActionContext()
@@ -35,4 +36,22 @@ export const setReviewerPublicKeyAction = userAction(async ({ publicKey, fingerp
             fingerprint,
         })
         .executeTakeFirstOrThrow(() => new ActionFailure({ message: 'Failed to set reviewer public key' }))
+
+    onUserPublicKeyCreated({ userId })
+}, setOrgUserPublicKeySchema)
+
+export const updateReviewerPublicKeyAction = userAction(async ({ publicKey, fingerprint }) => {
+    const userId = await getUserIdFromActionContext()
+
+    await db
+        .updateTable('userPublicKey')
+        .set({
+            publicKey: Buffer.from(publicKey),
+            fingerprint,
+            updatedAt: new Date(),
+        })
+        .where('userId', '=', userId)
+        .executeTakeFirstOrThrow(() => new ActionFailure({ message: 'Failed to update reviewer public key.' }))
+
+    onUserPublicKeyUpdated({ userId })
 }, setOrgUserPublicKeySchema)
diff --git a/src/server/events.ts b/src/server/events.ts
index 958151aaa..fd93e24a8 100644
--- a/src/server/events.ts
+++ b/src/server/events.ts
@@ -98,3 +98,11 @@ export const onUserRoleUpdate = deferred(
         await updateClerkUserMetadata(userId)
     },
 )
+
+export const onUserPublicKeyCreated = deferred(async ({ userId }: { userId: string }) => {
+    await audit({ userId, eventType: 'CREATED', recordType: 'USER', recordId: userId })
+})
+
+export const onUserPublicKeyUpdated = deferred(async ({ userId }: { userId: string }) => {
+    await audit({ userId, eventType: 'UPDATED', recordType: 'USER', recordId: userId })
+})


Reviews:
user: login: nathanstitt
body: looks great @stetradis a few comments but I'll approve so you can merge after addressing them
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T17:12:45Z

Review comments:
diff_hunk: @@ -0,0 +1,26 @@
+import { getReviewerPublicKeyAction } from '@/server/actions/user-keys.actions'
+import { UserLayout } from '@/components/layout/user-layout'
+import { AnonLayout } from '@/components/layout/anon-layout'
+import { ReactNode } from 'react'
+import { RegenerateKeys } from './regenerate-keys'
+
+export default async function ReviewerKeysPageLayout({
+    children,
+}: Readonly<{
+    children: ReactNode
+}>) {
+    const publicKey = await getReviewerPublicKeyAction()
+    const clerkPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''
+
+    if (publicKey) {
+        // User has a key - show UserLayout
+        return (
+            <UserLayout>
+                <RegenerateKeys />
+            </UserLayout>
+        )
+    } else {
+        // No key, first-time generation - show AnonLayout
+        return <AnonLayout clerkPublishableKey={clerkPublishableKey}>{children}</AnonLayout>
+    }
+}
path: src/app/account/keys/layout.tsx
user: login: nathanstitt
body: future work: I think we're also reading the `process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in other layouts, lets split AnonLayout so part can be 'use server' and read the env var itself, that way we're not repeating this everywhere
created_at: 2025-05-22T17:06:15Z
author_association: MEMBER
diff_hunk: @@ -99,20 +99,22 @@ export function NavbarProfileMenu() {
                     tabIndex={opened ? 0 : -1}
                     ref={accountMenuItemRef}
                 />
-                {/* TODO: restore once page is built*/}
-                {/* <NavLink
-                    label="Reviewer Key"
-                    leftSection={<Lock aria-hidden="true" />}
-                    onClick={() => {
-                        window.alert('404. Design under construction')
-                    }}
-                    c="white"
-                    className={styles.navLinkProfileHover}
-                    role="menuitem"
-                    aria-label="Reviewer Key" 
-                    tabIndex={opened ? 0 : -1}
-                    ref={reviewerKeyMenuItemRef}
-                /> */}
+
+                {auth.isReviewer && (
+                    <NavLink
+                        label="Reviewer Key"
+                        leftSection={<Lock aria-hidden="true" />}
+                        onClick={() => {
+                            router.push('/account/keys')
+                        }}
+                        c="white"
+                        className={styles.navLinkProfileHover}
+                        role="menuitem"
+                        aria-label="Reviewer Key"
+                        tabIndex={opened ? 0 : -1}
+                        ref={reviewerKeyMenuItemRef}
+                    />
+                )}
                 <NavLink
path: src/components/layout/navbar-profile-menu.tsx
user: login: nathanstitt
body: can you extract this into it's own `<ReviewerKeyLink />` component which has a `if (!auth.isReviewer) return null` in it?  That will eliminate the large `{` `}` block
created_at: 2025-05-22T17:08:28Z
author_association: MEMBER
diff_hunk: @@ -35,4 +36,22 @@ export const setReviewerPublicKeyAction = userAction(async ({ publicKey, fingerp
             fingerprint,
         })
         .executeTakeFirstOrThrow(() => new ActionFailure({ message: 'Failed to set reviewer public key' }))
+
+    onUserPublicKeyCreated({ userId })
+}, setOrgUserPublicKeySchema)
+
+export const updateReviewerPublicKeyAction = userAction(async ({ publicKey, fingerprint }) => {
+    const userId = await getUserIdFromActionContext()
+
+    await db
+        .updateTable('userPublicKey')
+        .set({
+            publicKey: Buffer.from(publicKey),
+            fingerprint,
+            updatedAt: new Date(),
+        })
+        .where('userId', '=', userId)
+        .executeTakeFirstOrThrow(() => new ActionFailure({ message: 'Failed to update reviewer public key.' }))
path: src/server/actions/user-keys.actions.ts
user: login: nathanstitt
body: the property in the ActionFailure object will look for a form input with a matching name to display errors on, or if not will capitalize it and make a string shown in a toast.  This will end up being something like: `Message Failed to update reviewer public key.` which reads a little awkward.  Maybe it'd be better to use `error: key did not update in database` so it reads `Error key did not update..`
```suggestion
        .executeTakeFirstOrThrow(() => new ActionFailure({ error: 'public key was not saved.' }))
```
created_at: 2025-05-22T17:12:07Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-22T18:06:45Z
body: 
### Total coverage

| Lines           | Branches           | Functions           | Statements           |
| --------------- | ------------------ | ------------------- | -------------------- |
| 88.42% (+0.47%) | 58.87% (+1.11%) | 82.82% (+0.45%) | 87.55% (+0.54%) | 


#### Detailed report

<details><summary>1 file with a coverage regression</summary>

|   | File | Lines | Branches | Functions | Statements |
| - | ---- | ----- | -------- | --------- | ---------- |
| ✅ | src/app/account/keys/generate-keys.tsx | 100.00% | 88.88% (+5.55%) | 100.00% | 100.00% |,
| ✅ | src/app/account/keys/regenerate-keys.test.tsx | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) |,
| ✅ | src/app/account/keys/regenerate-keys.tsx | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) |,
| ✅ | src/components/auth.tsx | 93.33% (+33.33%) | 40.90% (+18.18%) | 100.00% (+33.34%) | 78.94% (+31.58%) |,
| ✅ | src/components/layout/anon-layout-shell.test.tsx | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) |,
| ✅ | src/components/layout/anon-layout-shell.tsx | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) |,
| 🚫 | src/components/layout/navbar-profile-menu.tsx | 55.31% (+4.10%) | 11.53% (+3.20%) | 13.33% (-0.95%) | 56.25% (+5.04%) |
</details>

<!-- This comment was produced by coverage-diff-action -->
author_association: CONTRIBUTOR