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

@ -47,7 +47,8 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
tenant from the hostname, calls `http://<tenant>.guess.local:8080/api`, and
sends `X-Tenant-Id` on every request type. Other deployments use same-origin
`/api`. Keep this contract documented in `README.md` and `frontend/README.md`.
`/api`. Generated participant links must use the validated matching tenant
origin. Keep this contract documented in `README.md` and `frontend/README.md`.
Goose files in `backend/migrations` are schema-only and run in every environment.
`internal/database/seed/common.sql` contains shared reference data;

View file

@ -30,7 +30,9 @@ Open <http://nmbs.guess.local:5173> or <http://ypto.guess.local:5173>. The
hostname's first label is the tenant ID: the frontend calls
`http://<tenant>.guess.local:8080/api` and sends the same value in the
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream
requests. The backend uses that tenant ID as the isolated PostgreSQL schema.
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and
builds participant invitation and meeting links from the matching tenant browser
origin instead of the static localhost fallback.
Sign in with:

View file

@ -8,6 +8,7 @@ import (
"log/slog"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -368,7 +369,7 @@ func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) {
}
response := make([]webmodel.InvitationResponse, len(values))
for i, value := range values {
response[i] = webmapper.InvitationToWeb(value, h.frontendURL)
response[i] = webmapper.InvitationToWeb(value, h.frontendURLForRequest(r))
}
writeJSON(w, http.StatusOK, response)
}
@ -383,7 +384,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL))
writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) {
@ -391,7 +392,7 @@ func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InvitationReviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.InvitationReviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) SetInvitationOutcome(w http.ResponseWriter, r *http.Request) {
@ -406,7 +407,7 @@ func (h *Handler) SetInvitationOutcome(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InvitationToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.InvitationToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) CreateScheduledInterview(w http.ResponseWriter, r *http.Request) {
@ -422,7 +423,7 @@ func (h *Handler) CreateScheduledInterview(w http.ResponseWriter, r *http.Reques
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusCreated, webmapper.ScheduledInterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusCreated, webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) ListScheduledInterviews(w http.ResponseWriter, r *http.Request) {
@ -432,7 +433,7 @@ func (h *Handler) ListScheduledInterviews(w http.ResponseWriter, r *http.Request
}
response := make([]webmodel.ScheduledInterviewResponse, len(values))
for i, value := range values {
response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL)
response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r))
}
writeJSON(w, http.StatusOK, response)
}
@ -442,7 +443,7 @@ func (h *Handler) GetScheduledInterview(w http.ResponseWriter, r *http.Request)
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) UpdateScheduledInterviewStatus(w http.ResponseWriter, r *http.Request) {
@ -455,7 +456,7 @@ func (h *Handler) UpdateScheduledInterviewStatus(w http.ResponseWriter, r *http.
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) UpdateScheduledInterviewDocument(w http.ResponseWriter, r *http.Request) {
@ -470,7 +471,7 @@ func (h *Handler) UpdateScheduledInterviewDocument(w http.ResponseWriter, r *htt
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) ListInterviewNotes(w http.ResponseWriter, r *http.Request) {
@ -505,7 +506,7 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
}
response := make([]webmodel.ScheduledInterviewResponse, len(values))
for i, value := range values {
response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL)
response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r))
}
writeJSON(w, http.StatusOK, response)
}
@ -520,7 +521,7 @@ func (h *Handler) UpdateInbox(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) ListCalendar(w http.ResponseWriter, r *http.Request) {
@ -530,7 +531,7 @@ func (h *Handler) ListCalendar(w http.ResponseWriter, r *http.Request) {
}
response := make([]webmodel.ScheduledInterviewResponse, len(values))
for i, value := range values {
response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL)
response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURLForRequest(r))
}
writeJSON(w, http.StatusOK, response)
}
@ -605,7 +606,7 @@ func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) AcceptInterview(w http.ResponseWriter, r *http.Request) {
@ -613,7 +614,7 @@ func (h *Handler) AcceptInterview(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) SaveAnswer(w http.ResponseWriter, r *http.Request) {
@ -636,7 +637,23 @@ func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURLForRequest(r)))
}
func (h *Handler) frontendURLForRequest(r *http.Request) string {
tenant := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Tenant-Id")))
origin := strings.TrimRight(strings.TrimSpace(r.Header.Get("Origin")), "/")
if tenant == "" || origin == "" {
return h.frontendURL
}
parsed, err := url.Parse(origin)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return h.frontendURL
}
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") {
return origin
}
return h.frontendURL
}
func readUpload(r *http.Request, field string) ([]byte, string, string, error) {

View file

@ -0,0 +1,28 @@
package handler
import (
"net/http/httptest"
"testing"
)
func TestFrontendURLForTenantRequest(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "http://ypto.guess.local:5173")
request.Header.Set("X-Tenant-Id", "ypto")
if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
t.Fatalf("unexpected frontend URL %q", value)
}
}
func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "http://nmbs.guess.local:5173")
request.Header.Set("X-Tenant-Id", "ypto")
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
t.Fatalf("expected configured frontend URL, got %q", value)
}
}

View file

@ -41,6 +41,9 @@ tenant `nmbs`, calls `http://nmbs.guess.local:8080/api`, and sends
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
request. `ypto.guess.local` behaves identically for tenant `ypto`. Other
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
Invitation and participant-meeting URLs use the validated request origin so
links stay on the tenant hostname. Copy actions fall back to a temporary selected
textarea when the Clipboard API is unavailable on local HTTP domains.
Login stores the returned JWT in
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are

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')
}