diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0b72007..0192792 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 `.guess.local`. The frontend derives the tenant from the hostname, calls `http://.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; diff --git a/README.md b/README.md index 81a2d84..b38df77 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ Open or . The hostname's first label is the tenant ID: the frontend calls `http://.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: diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index 6018601..2a5b999 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -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) { diff --git a/backend/internal/web/handler/handler_test.go b/backend/internal/web/handler/handler_test.go new file mode 100644 index 0000000..fa46983 --- /dev/null +++ b/backend/internal/web/handler/handler_test.go @@ -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) + } +} diff --git a/frontend/README.md b/frontend/README.md index 1e71bd0..e8b872a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 `. API failures are diff --git a/frontend/src/components/actions/copyText.test.ts b/frontend/src/components/actions/copyText.test.ts new file mode 100644 index 0000000..12719a8 --- /dev/null +++ b/frontend/src/components/actions/copyText.test.ts @@ -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() +}) diff --git a/frontend/src/components/actions/copyText.ts b/frontend/src/components/actions/copyText.ts new file mode 100644 index 0000000..38e1477 --- /dev/null +++ b/frontend/src/components/actions/copyText.ts @@ -0,0 +1,21 @@ +export async function copyText(value: string): Promise { + 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') +} diff --git a/frontend/src/components/actions/index.ts b/frontend/src/components/actions/index.ts index e1d29bd..b321611 100644 --- a/frontend/src/components/actions/index.ts +++ b/frontend/src/components/actions/index.ts @@ -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' diff --git a/frontend/src/features/interviews/ScheduledInterviewPage.tsx b/frontend/src/features/interviews/ScheduledInterviewPage.tsx index 500e617..d4583cf 100644 --- a/frontend/src/features/interviews/ScheduledInterviewPage.tsx +++ b/frontend/src/features/interviews/ScheduledInterviewPage.tsx @@ -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') diff --git a/frontend/src/features/jobs/pages.tsx b/frontend/src/features/jobs/pages.tsx index 77619cc..4cc2b0c 100644 --- a/frontend/src/features/jobs/pages.tsx +++ b/frontend/src/features/jobs/pages.tsx @@ -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') }