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:
parent
98f47df8c1
commit
314c783659
10 changed files with 117 additions and 21 deletions
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
28
backend/internal/web/handler/handler_test.go
Normal file
28
backend/internal/web/handler/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
23
frontend/src/components/actions/copyText.test.ts
Normal file
23
frontend/src/components/actions/copyText.test.ts
Normal 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()
|
||||
})
|
||||
21
frontend/src/components/actions/copyText.ts
Normal file
21
frontend/src/components/actions/copyText.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue