fix: preserve tenant in participant links

Build invitation and meeting URLs from the validated tenant origin and fall back when local HTTP blocks the Clipboard API.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-10-01 17:54:01 +02:00
parent 98f47df8c1
commit 314c783659
10 changed files with 117 additions and 21 deletions

View file

@ -0,0 +1,23 @@
import { afterEach, expect, it, vi } from 'vitest'
import { copyText } from './copyText'
const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand')
afterEach(() => {
vi.restoreAllMocks()
if (originalExecCommand) {
Object.defineProperty(document, 'execCommand', originalExecCommand)
} else {
Reflect.deleteProperty(document, 'execCommand')
}
})
it('falls back to a selected textarea when the Clipboard API is unavailable', async () => {
const copy = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', { configurable: true, value: copy })
await copyText('http://ypto.guess.local:5173/participant/token')
expect(copy).toHaveBeenCalledWith('copy')
expect(document.querySelector('textarea')).toBeNull()
})

View file

@ -0,0 +1,21 @@
export async function copyText(value: string): Promise<void> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
return
}
} catch {
// Local HTTP tenant hosts may not receive Clipboard API permission.
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.append(textarea)
textarea.select()
const copied = typeof document.execCommand === 'function' && document.execCommand('copy')
textarea.remove()
if (!copied) throw new Error('clipboard copy failed')
}

View file

@ -1,4 +1,5 @@
export { useAuthSession } from './Account'
export { copyText } from './copyText'
export { ErrorAlert } from './ErrorAlert'
export { errorMessage, message } from './errorMessage'
export { Loading } from './Loading'

View file

@ -2,7 +2,7 @@ import { type Dispatch, type FormEvent, type SetStateAction, useEffect, useState
import { Link, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { useDomainEvent } from '../../app/useDomainEvent'
import { errorMessage } from '../../components/actions'
import { copyText, errorMessage } from '../../components/actions'
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
import type { InterviewEvent, InterviewNote, ScheduledInterview } from '../../types'
import { DateAndPlace } from './components/InterviewCard'
@ -111,7 +111,7 @@ export function ScheduledInterviewPage() {
if (!interview) return
setError('')
try {
await navigator.clipboard.writeText(interview.candidateUrl)
await copyText(interview.candidateUrl)
setLinkCopied(true)
} catch {
setError('Could not copy the participant interview link')

View file

@ -15,7 +15,7 @@ import {
import { type ReactNode, SyntheticEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api/client'
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { copyText, ErrorAlert, errorMessage, Loading } from '../../components/actions'
import { FormActions, FormField } from '../../components/form'
import { EmptyState, Fact, Metric, StatusBadge } from '../../components/ui/Display'
import { NotFound } from '../../components/ui/NotFound'
@ -522,7 +522,7 @@ function InvitationsView({
async function copy(url: string) {
try {
await navigator.clipboard.writeText(url)
await copyText(url)
} catch {
setError('Could not copy the participant URL')
}