Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { maskSigningSecret } from '../widget-signing-secret'

const { mutateAsync, copyWithFallback, toast } = vi.hoisted(() => ({
mutateAsync: vi.fn(),
copyWithFallback: vi.fn(),
toast: { success: vi.fn(), error: vi.fn() },
}))

vi.mock('@/lib/client/mutations/settings', () => ({
useRegenerateWidgetSecret: () => ({
mutateAsync,
isPending: false,
}),
}))

vi.mock('@/components/admin/activation-action-button', () => ({
copyWithFallback: (...args: unknown[]) => copyWithFallback(...args),
}))

vi.mock('sonner', () => ({
toast,
}))

describe('maskSigningSecret', () => {
it('keeps the prefix and masks the rest', () => {
expect(maskSigningSecret('wgt_abc123secret')).toBe('wgt_abc1••••••••')
})
})

describe('WidgetSigningSecret', () => {
beforeEach(() => {
mutateAsync.mockReset()
copyWithFallback.mockReset()
copyWithFallback.mockResolvedValue(undefined)
mutateAsync.mockResolvedValue('wgt_new')
toast.success.mockReset()
toast.error.mockReset()
})

it('masks the secret until reveal', async () => {
const { WidgetSigningSecret } = await import('../widget-signing-secret')
const secret = 'wgt_abc123secret'
render(<WidgetSigningSecret secret={secret} />)

expect(screen.getByTestId('signing-secret')).toHaveTextContent(maskSigningSecret(secret))
expect(screen.queryByText(secret)).toBeNull()

fireEvent.click(screen.getByRole('button', { name: 'Reveal signing secret' }))
expect(screen.getByTestId('signing-secret')).toHaveTextContent(secret)
})

it('copies the full secret', async () => {
const { WidgetSigningSecret } = await import('../widget-signing-secret')
render(<WidgetSigningSecret secret="wgt_abc123secret" />)

fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => {
expect(copyWithFallback).toHaveBeenCalledWith('wgt_abc123secret')
})
})

it('regenerates after confirm', async () => {
const { WidgetSigningSecret } = await import('../widget-signing-secret')
render(<WidgetSigningSecret secret="wgt_abc123secret" />)

fireEvent.click(screen.getByRole('button', { name: 'Regenerate…' }))
expect(screen.getByText('Regenerate signing secret?')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Regenerate secret' }))

await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledOnce()
})
expect(toast.success).toHaveBeenCalledWith('Signing secret regenerated')
})

it('keeps the dialog open and toasts when regenerate fails', async () => {
mutateAsync.mockRejectedValue(new Error('nope'))
const { WidgetSigningSecret } = await import('../widget-signing-secret')
render(<WidgetSigningSecret secret="wgt_abc123secret" />)

fireEvent.click(screen.getByRole('button', { name: 'Regenerate…' }))
fireEvent.click(screen.getByRole('button', { name: 'Regenerate secret' }))

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Could not regenerate the signing secret')
})
expect(screen.getByText('Regenerate signing secret?')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useState } from 'react'
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
import { copyWithFallback } from '@/components/admin/activation-action-button'
import { useRegenerateWidgetSecret } from '@/lib/client/mutations/settings'

export function maskSigningSecret(secret: string): string {
return `${secret.slice(0, 8)}${'•'.repeat(8)}`
}

export function WidgetSigningSecret({ secret }: { secret: string }) {
const [revealed, setRevealed] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [copying, setCopying] = useState(false)
const regenerate = useRegenerateWidgetSecret()

async function copySecret() {
setCopying(true)
try {
await copyWithFallback(secret)
toast.success('Copied')
} catch {
toast.error('Copy failed. Select the text and copy it manually.')
} finally {
setCopying(false)
}
}

return (
<div className="space-y-3">
<div>
<p className="text-sm font-medium">Signing secret</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Paste this into your app&apos;s server env (any name). Do not add it to Quackback Cloud or
your Quackback host. Some older notes called it QUACKBACK_WIDGET_SECRET — same value.
</p>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
<code className="min-w-0 flex-1 truncate font-mono text-xs" data-testid="signing-secret">
{revealed ? secret : maskSigningSecret(secret)}
</code>
<div className="flex shrink-0 flex-wrap gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRevealed((value) => !value)}
aria-label={revealed ? 'Hide signing secret' : 'Reveal signing secret'}
>
{revealed ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
{revealed ? 'Hide' : 'Reveal'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void copySecret()}
disabled={copying}
>
<ClipboardDocumentIcon className="h-4 w-4" />
{copying ? 'Copying…' : 'Copy'}
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={regenerate.isPending}
onClick={() => setConfirmOpen(true)}
>
Regenerate…
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Regenerate signing secret?"
description="Existing identify tokens stop working until you update the secret in your product and redeploy. The launcher keeps working."
warning={{
title: 'Identify breaks until you deploy the new secret',
description:
'The old secret is invalidated immediately. Anonymous visitors are unaffected.',
}}
confirmLabel="Regenerate secret"
variant="destructive"
isPending={regenerate.isPending}
onConfirm={async () => {
try {
await regenerate.mutateAsync()
toast.success('Signing secret regenerated')
setRevealed(false)
setConfirmOpen(false)
} catch {
toast.error('Could not regenerate the signing secret')
}
}}
/>
</div>
)
}
77 changes: 77 additions & 0 deletions apps/web/src/components/shared/__tests__/confirm-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ConfirmDialog } from '../confirm-dialog'

describe('ConfirmDialog', () => {
it('keeps the dialog open for an async confirm and ignores a second click', async () => {
let resolveConfirm: (() => void) | undefined
const onConfirm = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveConfirm = resolve
})
)
const onOpenChange = vi.fn()

render(
<ConfirmDialog
open
onOpenChange={onOpenChange}
title="Rotate?"
confirmLabel="Rotate now"
onConfirm={onConfirm}
/>
)

const confirm = screen.getByRole('button', { name: 'Rotate now' })
fireEvent.click(confirm)
fireEvent.click(confirm)

expect(onConfirm).toHaveBeenCalledOnce()
expect(onOpenChange).not.toHaveBeenCalled()
expect(confirm).toBeDisabled()

resolveConfirm?.()
await waitFor(() => {
expect(confirm).not.toBeDisabled()
})
})

it('does not reject when the async confirm fails', async () => {
const onConfirm = vi.fn(() => Promise.reject(new Error('nope')))

render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title="Rotate?"
confirmLabel="Rotate now"
onConfirm={onConfirm}
/>
)

fireEvent.click(screen.getByRole('button', { name: 'Rotate now' }))
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledOnce()
})
expect(screen.getByText('Rotate?')).toBeInTheDocument()
})

it('lets a sync confirm close the dialog', () => {
const onConfirm = vi.fn()

render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title="Delete?"
confirmLabel="Delete"
onConfirm={onConfirm}
/>
)

fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
})
41 changes: 37 additions & 4 deletions apps/web/src/components/shared/confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -39,8 +40,23 @@ export function ConfirmDialog({
onConfirm,
children,
}: ConfirmDialogProps) {
const startedRef = useRef(false)
const [started, setStarted] = useState(false)
const busy = Boolean(isPending) || started

function resetStarted() {
startedRef.current = false
setStarted(false)
}

return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) resetStarted()
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
Expand All @@ -65,10 +81,27 @@ export function ConfirmDialog({
)}

<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>{cancelLabel}</AlertDialogCancel>
<AlertDialogCancel disabled={busy}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isPending}
onClick={(event) => {
if (busy || startedRef.current) {
event.preventDefault()
return
}
let result: void | Promise<void>
try {
result = onConfirm()
} catch {
return
}
if (result && typeof result.then === 'function') {
event.preventDefault()
startedRef.current = true
setStarted(true)
void result.catch(() => undefined).finally(resetStarted)
}
}}
disabled={busy}
className={cn(variant === 'destructive' && buttonVariants({ variant: 'destructive' }))}
>
{confirmLabel}
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/lib/client/mutations/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const invalidateQueries = vi.fn()
const setQueryData = vi.fn()
const updateThemeFn = vi.fn(async () => ({ ok: true }))
const updateCustomCssFn = vi.fn(async () => ({ ok: true }))

Expand All @@ -10,7 +11,7 @@ vi.mock('@tanstack/react-query', async () => {
return {
...actual,
useMutation: vi.fn((options: unknown) => options),
useQueryClient: vi.fn(() => ({ invalidateQueries })),
useQueryClient: vi.fn(() => ({ invalidateQueries, setQueryData })),
}
})

Expand Down Expand Up @@ -59,12 +60,13 @@ describe('settings config mutations cache invalidation', () => {
expect(result).toBeInstanceOf(Promise)
})

it('useRegenerateWidgetSecret.onSuccess awaits invalidation of the widgetSecret query', async () => {
it('useRegenerateWidgetSecret.onSuccess writes the new secret then awaits invalidation', async () => {
const { useRegenerateWidgetSecret } = await import('../settings')
const mutation = useRegenerateWidgetSecret() as { onSuccess?: () => unknown }
const mutation = useRegenerateWidgetSecret() as { onSuccess?: (secret: string) => unknown }

const result = mutation.onSuccess?.()
const result = mutation.onSuccess?.('wgt_new')

expect(setQueryData).toHaveBeenCalledWith(['settings', 'widgetSecret'], 'wgt_new')
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['settings', 'widgetSecret'] })
expect(result).toBeInstanceOf(Promise)
})
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/client/mutations/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,10 @@ export function useRegenerateWidgetSecret() {

return useMutation({
mutationFn: () => regenerateWidgetSecretFn(),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: settingsQueries.widgetSecret().queryKey }),
onSuccess: (secret) => {
queryClient.setQueryData(settingsQueries.widgetSecret().queryKey, secret)
return queryClient.invalidateQueries({ queryKey: settingsQueries.widgetSecret().queryKey })
},
})
}

Expand Down
Loading
Loading