diff --git a/src/App.tsx b/src/App.tsx index 264e07f1..20109145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { ManageProductionsPage } from "./components/manage-productions-page/mana import { CreateProductionPage } from "./components/create-production/create-production-page.tsx"; import { useSetupTokenRefresh } from "./hooks/use-reauth.tsx"; import { TUserSettings } from "./components/user-settings/types"; +import { RequireNonGuest } from "./components/auth/require-non-guest.tsx"; import { PresetProvider } from "./contexts/preset-context.tsx"; const DisplayBoxPositioningContainer = styled(FlexContainer)` @@ -153,21 +154,29 @@ const AppContent = ({ setApiError(true)} /> + + setApiError(true)} /> + } errorElement={} /> } + element={ + + + + } errorElement={} /> setApiError(true)} - /> + + setApiError(true)} + /> + } errorElement={} /> @@ -181,7 +190,14 @@ const AppContent = ({ element={} errorElement={} /> - } /> + + + + } + /> } /> diff --git a/src/components/auth/require-non-guest.test.tsx b/src/components/auth/require-non-guest.test.tsx new file mode 100644 index 00000000..f9db7abd --- /dev/null +++ b/src/components/auth/require-non-guest.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { RequireNonGuest } from "./require-non-guest"; + +const mockUseIsGuest = vi.fn(); +vi.mock("../../hooks/use-is-guest", () => ({ + useIsGuest: () => mockUseIsGuest(), +})); + +const mockGetPath = vi.fn(); +vi.mock("../../utils/guest-session", () => ({ + guestSession: { + getPath: () => mockGetPath(), + }, +})); + +afterEach(() => { + mockUseIsGuest.mockReset(); + mockGetPath.mockReset(); +}); + +const renderAt = (path: string) => + render( + + + +
Landing page
+ + } + /> + Calls page} /> +
+
+ ); + +describe("RequireNonGuest", () => { + it("renders children for a normal user", () => { + mockUseIsGuest.mockReturnValue(false); + + renderAt("/"); + + expect(screen.getByText("Landing page")).toBeInTheDocument(); + }); + + it("redirects a guest to their invited path", () => { + mockUseIsGuest.mockReturnValue(true); + mockGetPath.mockReturnValue("/calls?lines=p:l&guest=1"); + + renderAt("/"); + + expect(screen.queryByText("Landing page")).toBeNull(); + expect(screen.getByText("Calls page")).toBeInTheDocument(); + }); + + it("falls back to /calls when no invited path was stored", () => { + mockUseIsGuest.mockReturnValue(true); + mockGetPath.mockReturnValue(null); + + renderAt("/"); + + expect(screen.queryByText("Landing page")).toBeNull(); + expect(screen.getByText("Calls page")).toBeInTheDocument(); + }); +}); diff --git a/src/components/auth/require-non-guest.tsx b/src/components/auth/require-non-guest.tsx new file mode 100644 index 00000000..3314e2b6 --- /dev/null +++ b/src/components/auth/require-non-guest.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from "react"; +import { Navigate } from "react-router"; +import { useIsGuest } from "../../hooks/use-is-guest"; +import { guestSession } from "../../utils/guest-session"; + +type RequireNonGuestProps = { + children: ReactNode; +}; + +export const RequireNonGuest = ({ children }: RequireNonGuestProps) => { + const isGuest = useIsGuest(); + + if (isGuest) { + return ; + } + + return children; +}; diff --git a/src/components/calls-page/calls-page.tsx b/src/components/calls-page/calls-page.tsx index fad32c05..e17a64dd 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "../page-layout/page-header"; import { useAudioCue } from "../production-line/use-audio-cue"; import { useGlobalHotkeys } from "../production-line/use-line-hotkeys"; import { ShareUrlModal } from "../share-url-modal/share-url-modal"; +import { useIsGuest } from "../../hooks/use-is-guest"; import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; import { UserSettings } from "../user-settings/user-settings"; import { ConfirmationModal } from "../verify-decision/confirmation-modal"; @@ -155,6 +156,7 @@ export const CallsPage = () => { const { productionId: paramProductionId, lineId: paramLineId } = useParams(); const { search } = useLocation(); + const isGuest = useIsGuest(); const autoCompanionUrl = parseCompanionParam( new URLSearchParams(search).get("companion") ); @@ -452,7 +454,7 @@ export const CallsPage = () => { { ) : undefined } - hasNavigateToRoot + hasNavigateToRoot={!isGuest} onNavigateToRoot={() => { if (isEmpty) { runExitAllCalls(); @@ -549,22 +551,24 @@ export const CallsPage = () => { /> )} - {addCallActive && (productionId || addCallPreSelected) && ( - setAddCallActive(false)} - className="calls-page" - hideUsername - hideDevices - /> - )} + {addCallActive && + !isGuest && + (productionId || addCallPreSelected) && ( + setAddCallActive(false)} + className="calls-page" + hideUsername + hideDevices + /> + )} {!!( userSettings && userSettings.username && diff --git a/src/components/calls-page/header-actions.tsx b/src/components/calls-page/header-actions.tsx index 6bf31e9c..bfea9d3b 100644 --- a/src/components/calls-page/header-actions.tsx +++ b/src/components/calls-page/header-actions.tsx @@ -4,6 +4,7 @@ import { MicMuted, MicUnmuted } from "../../assets/icons/icon"; import { isMobile, isTablet } from "../../bowser"; import { PrimaryButton, SecondaryButton } from "../form-elements/form-elements"; import { ConnectToWSButton } from "./connect-to-ws-button"; +import { useIsGuest } from "../../hooks/use-is-guest"; import { useGlobalMuteToggle } from "./use-global-mute-toggle"; import { SavePresetModal } from "./save-preset-modal"; import { useGlobalState } from "../../global-state/context-provider"; @@ -104,6 +105,8 @@ export const HeaderActions = ({ setIsMasterInputMuted, setIsSettingGlobalMute, }); + const isGuest = useIsGuest(); + const [showPresetModal, setShowPresetModal] = useState(false); const activeCompanionUrl = @@ -123,7 +126,7 @@ export const HeaderActions = ({ {isMasterInputMuted ? : } )} - {!isEmpty && !isMobile && !isTablet && ( + {!isEmpty && !isMobile && !isTablet && !isGuest && ( )} - {!isEmpty && ( + {!isEmpty && !isGuest && ( setShowPresetModal(true)}> {isMobile ? "Save" : "Save as Configuration"} )} - {!isEmpty && ( + {!isEmpty && !isGuest && ( { + render( + + ); + return onRefresh; +}; + +describe("ShareLineLinkModal", () => { + it("restricts recipients by default", () => { + renderModal(); + + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("asks for an unrestricted link when the box is cleared", async () => { + const onRefresh = renderModal(); + + await userEvent.click(screen.getByRole("checkbox")); + + expect(onRefresh).toHaveBeenCalledWith({ guest: false }); + }); + + it("asks for a restricted link when the box is ticked again", async () => { + const onRefresh = renderModal(); + + await userEvent.click(screen.getByRole("checkbox")); + await userEvent.click(screen.getByRole("checkbox")); + + expect(onRefresh).toHaveBeenLastCalledWith({ guest: true }); + }); +}); diff --git a/src/components/generate-urls/share-line-link/share-line-link-modal.tsx b/src/components/generate-urls/share-line-link/share-line-link-modal.tsx index 40dde9b1..b40ec681 100644 --- a/src/components/generate-urls/share-line-link/share-line-link-modal.tsx +++ b/src/components/generate-urls/share-line-link/share-line-link-modal.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import styled from "@emotion/styled"; import { Modal } from "../../modal/modal"; +import { DEFAULT_RESTRICT_SHARE } from "../../../utils/guest-session"; type TShareLineLinkModalProps = { urls: string[]; isCopyProduction?: boolean; - onRefresh: () => void; + onRefresh: (options: { guest: boolean }) => void; onClose: () => void; }; @@ -23,6 +24,25 @@ const Note = styled.p` line-height: 1.4; `; +const CheckboxRow = styled.label` + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 1.25rem; + font-size: 1.4rem; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 1.6rem; + height: 1.6rem; + cursor: pointer; + accent-color: #59cbe8; + flex-shrink: 0; + } +`; + const RowsContainer = styled.div` display: flex; flex-direction: column; @@ -146,6 +166,7 @@ export const ShareLineLinkModal = ({ const [isLoading, setIsLoading] = useState(false); const [copied, setCopied] = useState(false); const [copiedRows, setCopiedRows] = useState>({}); + const [restrictAccess, setRestrictAccess] = useState(DEFAULT_RESTRICT_SHARE); useEffect(() => { const copyTimers = copyTimerRefs.current; @@ -156,15 +177,23 @@ export const ShareLineLinkModal = ({ }; }, []); - const handleRefresh = useCallback(() => { - setIsLoading(true); - setCopied(false); - setCopiedRows({}); - onRefresh(); - refreshTimerRef.current = setTimeout(() => { - setIsLoading(false); - }, 1000); - }, [onRefresh]); + const handleRefresh = useCallback( + (guest: boolean) => { + setIsLoading(true); + setCopied(false); + setCopiedRows({}); + onRefresh({ guest }); + refreshTimerRef.current = setTimeout(() => { + setIsLoading(false); + }, 1000); + }, + [onRefresh] + ); + + const handleRestrictToggle = (checked: boolean) => { + setRestrictAccess(checked); + handleRefresh(checked); + }; const handleCopySingle = () => { const url = urls[0]; @@ -172,7 +201,7 @@ export const ShareLineLinkModal = ({ navigator.clipboard.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); - handleRefresh(); + handleRefresh(restrictAccess); }); }; @@ -185,7 +214,7 @@ export const ShareLineLinkModal = ({ copyTimerRefs.current[index] = setTimeout(() => { setCopiedRows((prev) => ({ ...prev, [index]: false })); }, 2000); - handleRefresh(); + handleRefresh(restrictAccess); }); }; @@ -248,6 +277,18 @@ export const ShareLineLinkModal = ({ {singleLineLabel()} )} + + handleRestrictToggle(e.target.checked)} + /> + Restrict recipients to {isCopyProduction ? "these calls" : "this call"} + + + Recipients of a restricted link only see the call they were invited to. + This tailors their view — it is not an access-control boundary. + ); }; diff --git a/src/components/header.tsx b/src/components/header.tsx index ad63f364..31594c52 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -7,6 +7,7 @@ import { mediaQueries } from "./generic-components.ts"; import { useGlobalState } from "../global-state/context-provider.tsx"; import { useAudioCue } from "./production-line/use-audio-cue.ts"; import { ConfirmationModal } from "./verify-decision/confirmation-modal.tsx"; +import { useIsGuest } from "../hooks/use-is-guest.ts"; const HeaderWrapper = styled.div` width: 100%; @@ -14,7 +15,7 @@ const HeaderWrapper = styled.div` margin: 0 0 1rem 0; `; -const HomeButton = styled.button` +const HomeButton = styled.button<{ isGuest?: boolean }>` background: ${backgroundColour}; border: none; padding: 1rem; @@ -23,7 +24,7 @@ const HomeButton = styled.button` width: fit-content; font-size: 3rem; font-weight: semi-bold; - cursor: pointer; + cursor: ${({ isGuest }) => (isGuest ? "default" : "pointer")}; color: rgba(255, 255, 255, 0.87); svg { @@ -50,6 +51,7 @@ export const Header: FC = () => { const navigate = useNavigate(); const location = useLocation(); const { playExitSound } = useAudioCue(); + const isGuest = useIsGuest(); const isEmpty = Object.values(calls).length === 0; const runExitAllCalls = () => { @@ -69,6 +71,9 @@ export const Header: FC = () => { }; const returnToRoot = () => { + if (isGuest) { + return; + } if (location.pathname.includes("/line") && isEmpty) { runExitAllCalls(); } else if (location.pathname.includes("/line")) { @@ -81,7 +86,11 @@ export const Header: FC = () => { return ( <> - + Open Intercom diff --git a/src/components/production-line/call-header.tsx b/src/components/production-line/call-header.tsx index b6a4a36a..574c3ef7 100644 --- a/src/components/production-line/call-header.tsx +++ b/src/components/production-line/call-header.tsx @@ -20,6 +20,8 @@ import { TLine } from "./types"; import { TBasicProductionResponse } from "../../api/api"; import { KebabMenu } from "./kebab-menu"; import { useShareUrl } from "../../hooks/use-share-url"; +import { useIsGuest } from "../../hooks/use-is-guest"; +import { DEFAULT_RESTRICT_SHARE } from "../../utils/guest-session"; import { ShareLineLinkModal } from "../generate-urls/share-line-link/share-line-link-modal"; const CallHeaderTexts = styled(HeaderTexts)` @@ -151,6 +153,7 @@ export const CallHeaderComponent = ({ }) => { const [shareModalOpen, setShareModalOpen] = useState(false); const { shareUrl, url } = useShareUrl(); + const isGuest = useIsGuest(); const totalUsers = useMemo(() => { return line?.participants.filter((p) => !p.isWhip).length || 0; @@ -167,6 +170,7 @@ export const CallHeaderComponent = ({ shareUrl({ productionId: production.productionId, lineId: line.id, + guest: DEFAULT_RESTRICT_SHARE, }); } setShareModalOpen(true); @@ -174,14 +178,18 @@ export const CallHeaderComponent = ({ [production, line, shareUrl] ); - const handleShareRefresh = useCallback(() => { - if (production && line) { - shareUrl({ - productionId: production.productionId, - lineId: line.id, - }); - } - }, [production, line, shareUrl]); + const handleShareRefresh = useCallback( + ({ guest }: { guest: boolean }) => { + if (production && line) { + shareUrl({ + productionId: production.productionId, + lineId: line.id, + guest, + }); + } + }, + [production, line, shareUrl] + ); return ( @@ -237,7 +245,7 @@ export const CallHeaderComponent = ({ {totalUsers} - {production && line && ( + {production && line && !isGuest && ( ({ + useIsGuest: () => mockUseIsGuest(), +})); + +vi.mock("../../hooks/use-share-url", () => ({ + useShareUrl: () => ({ shareUrl: vi.fn(), url: "" }), +})); + +afterEach(() => { + mockUseIsGuest.mockReset(); +}); + +const renderMenu = (props?: { showHotkeys?: boolean }) => + render( + + ); + +describe("KebabMenu", () => { + it("offers Share and WebRTC to a normal user", async () => { + mockUseIsGuest.mockReturnValue(false); + renderMenu(); + + await userEvent.click(screen.getByRole("button", { name: "More options" })); + + expect(screen.getByRole("menuitem", { name: "Share" })).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "WebRTC" }) + ).toBeInTheDocument(); + }); + + it("renders nothing for a guest when hotkeys are unavailable", () => { + mockUseIsGuest.mockReturnValue(true); + const { container } = renderMenu(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("keeps Hotkeys but drops Share and WebRTC for a guest", async () => { + mockUseIsGuest.mockReturnValue(true); + renderMenu({ showHotkeys: true }); + + await userEvent.click(screen.getByRole("button", { name: "More options" })); + + expect( + screen.getByRole("menuitem", { name: "Hotkeys" }) + ).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Share" })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: "WebRTC" })).toBeNull(); + }); +}); diff --git a/src/components/production-line/kebab-menu.tsx b/src/components/production-line/kebab-menu.tsx index 5a5f3c72..be004fea 100644 --- a/src/components/production-line/kebab-menu.tsx +++ b/src/components/production-line/kebab-menu.tsx @@ -5,6 +5,8 @@ import { GenerateWhipWhepUrlModal } from "../generate-urls/generate-whip-whep-ur import { ShareLineLinkModal } from "../generate-urls/share-line-link/share-line-link-modal"; import { useShareUrl } from "../../hooks/use-share-url"; import { TBasicProductionResponse } from "../../api/api"; +import { useIsGuest } from "../../hooks/use-is-guest"; +import { DEFAULT_RESTRICT_SHARE } from "../../utils/guest-session"; import { TLine } from "./types"; const MenuWrapper = styled.div` @@ -94,6 +96,7 @@ export const KebabMenu = ({ const buttonRef = useRef(null); const dropdownRef = useRef(null); const { shareUrl, url } = useShareUrl(); + const isGuest = useIsGuest(); const openMenu = useCallback(() => { if (buttonRef.current) { @@ -138,20 +141,29 @@ export const KebabMenu = ({ shareUrl({ productionId: production.productionId, lineId: line.id, + guest: DEFAULT_RESTRICT_SHARE, }); } setActiveModal("share"); setIsOpen(false); }, [production, line, shareUrl]); - const handleShareRefresh = useCallback(() => { - if (production && line) { - shareUrl({ - productionId: production.productionId, - lineId: line.id, - }); - } - }, [production, line, shareUrl]); + const handleShareRefresh = useCallback( + ({ guest }: { guest: boolean }) => { + if (production && line) { + shareUrl({ + productionId: production.productionId, + lineId: line.id, + guest, + }); + } + }, + [production, line, shareUrl] + ); + + const canShowHotkeys = !!(showHotkeys && onOpenHotkeys); + + if (isGuest && !canShowHotkeys) return null; return ( @@ -174,35 +186,39 @@ export const KebabMenu = ({ top={dropdownPos.top} left={dropdownPos.left} > - {showHotkeys && onOpenHotkeys && ( + {canShowHotkeys && ( { - onOpenHotkeys(); + onOpenHotkeys?.(); setIsOpen(false); }} > Hotkeys )} - - Share - - { - setActiveModal("whip-whep"); - setIsOpen(false); - }} - > - WebRTC - + {!isGuest && ( + + Share + + )} + {!isGuest && ( + { + setActiveModal("whip-whep"); + setIsOpen(false); + }} + > + WebRTC + + )} , document.body )} diff --git a/src/components/production-list/copy-link.tsx b/src/components/production-list/copy-link.tsx index f33e14f1..867a4e09 100644 --- a/src/components/production-list/copy-link.tsx +++ b/src/components/production-list/copy-link.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { TBasicProductionResponse } from "../../api/api"; import { ShareIcon } from "../../assets/icons/icon"; import { useShareUrl } from "../../hooks/use-share-url"; +import { DEFAULT_RESTRICT_SHARE } from "../../utils/guest-session"; import { CopyIconWrapper } from "../copy-button/copy-components"; import { ShareLineLinkModal } from "../generate-urls/share-line-link/share-line-link-modal"; import { TLine } from "../production-line/types"; @@ -19,41 +20,39 @@ export const CopyLink = ({ const [isModalOpen, setIsModalOpen] = useState(false); const { shareUrl, url } = useShareUrl(); - const handleGenerateProductionUrls = useCallback(async () => { - const urls = await Promise.all( - production.lines.map(async (item) => { - const generatedUrl = await shareUrl({ - productionId: production.productionId, - lineId: item.id, - }); - return ` ${item.name}: ${generatedUrl}`; - }) - ); - setProductionUrls(urls); - }, [production.productionId, production.lines, shareUrl]); + const handleGenerateProductionUrls = useCallback( + async (guest: boolean) => { + const urls = await Promise.all( + production.lines.map(async (item) => { + const generatedUrl = await shareUrl({ + productionId: production.productionId, + lineId: item.id, + guest, + }); + return ` ${item.name}: ${generatedUrl}`; + }) + ); + setProductionUrls(urls); + }, + [production.productionId, production.lines, shareUrl] + ); - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); + const generate = (guest: boolean) => { if (isCopyProduction) { - handleGenerateProductionUrls(); + handleGenerateProductionUrls(guest); } else { shareUrl({ productionId: production.productionId, lineId: line.id, + guest, }); } - setIsModalOpen(true); }; - const handleRefresh = () => { - if (isCopyProduction) { - handleGenerateProductionUrls(); - } else { - shareUrl({ - productionId: production.productionId, - lineId: line.id, - }); - } + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + generate(DEFAULT_RESTRICT_SHARE); + setIsModalOpen(true); }; return ( @@ -69,7 +68,7 @@ export const CopyLink = ({ generate(guest)} onClose={() => setIsModalOpen(false)} /> )} diff --git a/src/components/share-url-modal/share-url-modal.tsx b/src/components/share-url-modal/share-url-modal.tsx index cb7a7d11..8013f8b6 100644 --- a/src/components/share-url-modal/share-url-modal.tsx +++ b/src/components/share-url-modal/share-url-modal.tsx @@ -1,7 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import styled from "@emotion/styled"; import { API } from "../../api/api"; import { Modal } from "../modal/modal"; +import { + DEFAULT_RESTRICT_SHARE, + appendGuestParam, +} from "../../utils/guest-session"; const Description = styled.p` font-size: 1.4rem; @@ -96,122 +100,66 @@ type FetchState = | { status: "ready"; url: string } | { status: "error" }; +const withParam = (path: string, key: string, value: string): string => + `${path}${path.includes("?") ? "&" : "?"}${key}=${value}`; + export const ShareUrlModal = ({ path, companionUrl, title = "Share", onClose, }: ShareUrlModalProps) => { - const [baseState, setBaseState] = useState({ status: "loading" }); - const [withCompanionState, setWithCompanionState] = - useState(null); + const [state, setState] = useState({ status: "loading" }); const [includeCompanion, setIncludeCompanion] = useState(false); + const [restrictAccess, setRestrictAccess] = useState(DEFAULT_RESTRICT_SHARE); const [copied, setCopied] = useState(false); - const postCopyCancel = useRef<(() => void) | null>(null); - - // Cancel any in-flight post-copy fetch on unmount. - useEffect(() => { - return () => { - postCopyCancel.current?.(); - }; - }, []); + const [nonce, setNonce] = useState(0); const companionHostPort = companionUrl ? companionUrl.replace(/^wss?:\/\//, "") : undefined; - const companionPath = companionHostPort - ? `${path}${path.includes("?") ? "&" : "?"}companion=${companionHostPort}` - : undefined; + const effectivePath = useMemo(() => { + let result = path; + if (includeCompanion && companionHostPort) { + result = withParam(result, "companion", companionHostPort); + } + if (restrictAccess) { + result = appendGuestParam(result); + } + return result; + }, [path, includeCompanion, companionHostPort, restrictAccess]); useEffect(() => { let cancelled = false; - API.shareUrl({ path }) + setState({ status: "loading" }); + API.shareUrl({ path: effectivePath }) .then((res) => { - if (!cancelled) setBaseState({ status: "ready", url: res.url }); + if (!cancelled) setState({ status: "ready", url: res.url }); }) .catch(() => { - if (!cancelled) setBaseState({ status: "error" }); + if (!cancelled) setState({ status: "error" }); }); return () => { cancelled = true; }; - }, [path]); - - const handleCompanionToggle = (checked: boolean) => { - setIncludeCompanion(checked); - setCopied(false); - - if (!checked || !companionPath) return; + }, [effectivePath, nonce]); - // Already fetched — reuse cached result - if (withCompanionState !== null) return; - - setWithCompanionState({ status: "loading" }); - API.shareUrl({ path: companionPath }) - .then((res) => { - setWithCompanionState({ status: "ready", url: res.url }); - }) - .catch(() => { - setWithCompanionState({ status: "error" }); - }); - }; - - // While companion fetch is in-flight, keep showing the base URL so the - // button doesn't flicker. Only switch once the companion result is ready. - const activeState = - includeCompanion && - companionPath && - withCompanionState?.status !== "loading" - ? (withCompanionState ?? baseState) - : baseState; - - const isLoading = activeState.status === "loading"; - const isError = activeState.status === "error"; - const url = activeState.status === "ready" ? activeState.url : ""; + const isLoading = state.status === "loading"; + const isError = state.status === "error"; + const url = state.status === "ready" ? state.url : ""; const handleCopy = () => { if (!url) return; navigator.clipboard.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); - - // Immediately start fetching a replacement so the modal always shows a fresh link. - if (includeCompanion && companionPath) { - setWithCompanionState({ status: "loading" }); - let cancelled = false; - API.shareUrl({ path: companionPath }) - .then((res) => { - if (!cancelled) - setWithCompanionState({ status: "ready", url: res.url }); - }) - .catch(() => { - if (!cancelled) setWithCompanionState({ status: "error" }); - }); - // Store the cancel flag on the closure; component unmount cleans up - // by capturing it in the outer ref below. - postCopyCancel.current = () => { - cancelled = true; - }; - } else { - setBaseState({ status: "loading" }); - let cancelled = false; - API.shareUrl({ path }) - .then((res) => { - if (!cancelled) setBaseState({ status: "ready", url: res.url }); - }) - .catch(() => { - if (!cancelled) setBaseState({ status: "error" }); - }); - postCopyCancel.current = () => { - cancelled = true; - }; - } + setNonce((n) => n + 1); }); }; const buttonLabel = () => { - if (copied) return "✓ Link copied!"; + if (copied) return "Link copied!"; if (isLoading) return "Generating link…"; if (isError) return "Failed to generate link"; return "Copy link"; @@ -229,7 +177,7 @@ export const ShareUrlModal = ({ copied={copied} isError={isError} isLoading={isLoading} - disabled={isLoading && !copied} + disabled={(isLoading && !copied) || isError} onClick={handleCopy} > {buttonLabel()} @@ -239,11 +187,29 @@ export const ShareUrlModal = ({ handleCompanionToggle(e.target.checked)} + onChange={(e) => { + setIncludeCompanion(e.target.checked); + setCopied(false); + }} /> Include companion URL )} + + { + setRestrictAccess(e.target.checked); + setCopied(false); + }} + /> + Restrict recipients to these calls + + + Recipients of a restricted link only see the calls they were invited to. + This tailors their view — it is not an access-control boundary. + ); }; diff --git a/src/hooks/use-is-guest.test.ts b/src/hooks/use-is-guest.test.ts new file mode 100644 index 00000000..4f4f2de1 --- /dev/null +++ b/src/hooks/use-is-guest.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useIsGuest } from "./use-is-guest"; +import { guestSession } from "../utils/guest-session"; + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe("useIsGuest", () => { + it("is false when no guest session was started", () => { + const { result } = renderHook(() => useIsGuest()); + + expect(result.current).toBe(false); + }); + + it("is true once a guest session exists", () => { + guestSession.start("/calls?lines=p:l&guest=1"); + + const { result } = renderHook(() => useIsGuest()); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/use-is-guest.ts b/src/hooks/use-is-guest.ts new file mode 100644 index 00000000..050a5ead --- /dev/null +++ b/src/hooks/use-is-guest.ts @@ -0,0 +1,7 @@ +import { useState } from "react"; +import { guestSession } from "../utils/guest-session"; + +export const useIsGuest = (): boolean => { + const [isGuest] = useState(() => guestSession.isGuest()); + return isGuest; +}; diff --git a/src/hooks/use-share-url.test.tsx b/src/hooks/use-share-url.test.tsx new file mode 100644 index 00000000..1c4e7262 --- /dev/null +++ b/src/hooks/use-share-url.test.tsx @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useShareUrl } from "./use-share-url"; + +const mockShareLine = vi.fn(); +vi.mock("../components/production-line/use-share-line", () => ({ + useShareLine: () => mockShareLine, +})); + +beforeEach(() => { + mockShareLine.mockReset(); + mockShareLine.mockResolvedValue({ url: "https://example.test/shared" }); +}); + +describe("useShareUrl", () => { + it("shares an unrestricted calls path by default", async () => { + const { result } = renderHook(() => useShareUrl()); + + await act(async () => { + await result.current.shareUrl({ productionId: "p1", lineId: "l1" }); + }); + + expect(mockShareLine).toHaveBeenCalledWith({ path: "/calls?lines=p1:l1" }); + }); + + it("adds the guest marker when the link is restricted", async () => { + const { result } = renderHook(() => useShareUrl()); + + await act(async () => { + await result.current.shareUrl({ + productionId: "p1", + lineId: "l1", + guest: true, + }); + }); + + expect(mockShareLine).toHaveBeenCalledWith({ + path: "/calls?lines=p1:l1&guest=1", + }); + }); + + it("returns an empty string when sharing fails", async () => { + mockShareLine.mockRejectedValue(new Error("nope")); + const { result } = renderHook(() => useShareUrl()); + + let returned: string | undefined; + await act(async () => { + returned = await result.current.shareUrl({ + productionId: "p1", + lineId: "l1", + }); + }); + + expect(returned).toBe(""); + }); +}); diff --git a/src/hooks/use-share-url.tsx b/src/hooks/use-share-url.tsx index 9534c4e5..9fc82b24 100644 --- a/src/hooks/use-share-url.tsx +++ b/src/hooks/use-share-url.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import logger from "../utils/logger"; import { useShareLine } from "../components/production-line/use-share-line"; import { buildCallsUrl } from "../utils/call-url"; +import { appendGuestParam } from "../utils/guest-session"; export const useShareUrl = () => { const [url, setUrl] = useState(""); @@ -10,11 +11,14 @@ export const useShareUrl = () => { const shareUrl = async ({ productionId, lineId, + guest = false, }: { productionId: string; lineId: string; + guest?: boolean; }) => { - const path = buildCallsUrl([{ productionId, lineId }]); + const callsPath = buildCallsUrl([{ productionId, lineId }]); + const path = guest ? appendGuestParam(callsPath) : callsPath; try { const res = await shareLine({ path }); diff --git a/src/main.tsx b/src/main.tsx index a01fb726..df080104 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,8 +2,11 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import { ErrorBoundary } from "./components/error-boundary.tsx"; +import { bootstrapGuestSession } from "./utils/guest-session.ts"; import "./index.css"; +bootstrapGuestSession(); + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/utils/guest-session.test.ts b/src/utils/guest-session.test.ts new file mode 100644 index 00000000..268d0b18 --- /dev/null +++ b/src/utils/guest-session.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + appendGuestParam, + bootstrapGuestSession, + guestSession, +} from "./guest-session"; + +const setUrl = (url: string) => { + const parsed = new URL(url, "http://localhost"); + vi.stubGlobal("location", { + pathname: parsed.pathname, + search: parsed.search, + }); +}; + +beforeEach(() => { + sessionStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe("appendGuestParam", () => { + it("adds the marker to a path without a query string", () => { + expect(appendGuestParam("/calls")).toBe("/calls?guest=1"); + }); + + it("appends to an existing query string", () => { + expect(appendGuestParam("/calls?lines=p:l")).toBe( + "/calls?lines=p:l&guest=1" + ); + }); +}); + +describe("bootstrapGuestSession", () => { + it("starts a guest session when the marker is present", () => { + setUrl("/calls?lines=p:l&guest=1"); + + bootstrapGuestSession(); + + expect(guestSession.isGuest()).toBe(true); + expect(guestSession.getPath()).toBe("/calls?lines=p:l&guest=1"); + }); + + it("does nothing without the marker", () => { + setUrl("/calls?lines=p:l"); + + bootstrapGuestSession(); + + expect(guestSession.isGuest()).toBe(false); + expect(guestSession.getPath()).toBeNull(); + }); + + it("ignores a marker with an unexpected value", () => { + setUrl("/calls?guest=0"); + + bootstrapGuestSession(); + + expect(guestSession.isGuest()).toBe(false); + }); + + it("ignores the marker outside a calls view", () => { + setUrl("/?guest=1"); + + bootstrapGuestSession(); + + expect(guestSession.isGuest()).toBe(false); + }); + + it("accepts the marker on a deep-linked production line", () => { + setUrl("/production-lines/production/p1/line/l1?guest=1"); + + bootstrapGuestSession(); + + expect(guestSession.isGuest()).toBe(true); + }); + + it("keeps the marker in the stored path so it survives a new tab", () => { + setUrl("/calls?lines=p:l&guest=1"); + + bootstrapGuestSession(); + + expect(guestSession.getPath()).toContain("guest=1"); + }); +}); + +describe("guestSession", () => { + it("reports a non-guest by default", () => { + expect(guestSession.isGuest()).toBe(false); + expect(guestSession.getPath()).toBeNull(); + }); + + it("survives a read after start", () => { + guestSession.start("/calls?lines=p:l&guest=1"); + + expect(guestSession.isGuest()).toBe(true); + expect(guestSession.getPath()).toBe("/calls?lines=p:l&guest=1"); + }); +}); diff --git a/src/utils/guest-session.ts b/src/utils/guest-session.ts new file mode 100644 index 00000000..fa80cb11 --- /dev/null +++ b/src/utils/guest-session.ts @@ -0,0 +1,51 @@ +export const GUEST_URL_PARAM = "guest"; + +export const DEFAULT_RESTRICT_SHARE = true; + +const GUEST_FLAG_KEY = "intercom_guest_session"; +const GUEST_PATH_KEY = "intercom_guest_path"; + +const read = (key: string): string | null => { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } +}; + +const write = (key: string, value: string): boolean => { + try { + sessionStorage.setItem(key, value); + return true; + } catch { + return false; + } +}; + +export const guestSession = { + isGuest: (): boolean => read(GUEST_FLAG_KEY) === "true", + + getPath: (): string | null => read(GUEST_PATH_KEY), + + start: (path: string): void => { + write(GUEST_FLAG_KEY, "true"); + write(GUEST_PATH_KEY, path); + }, +}; + +export const appendGuestParam = (path: string): string => + `${path}${path.includes("?") ? "&" : "?"}${GUEST_URL_PARAM}=1`; + +const isCallsPath = (pathname: string): boolean => + pathname === "/calls" || + pathname.startsWith("/calls/") || + pathname.startsWith("/production-lines/"); + +export const bootstrapGuestSession = (): void => { + const { pathname, search } = window.location; + + if (new URLSearchParams(search).get(GUEST_URL_PARAM) !== "1") return; + if (!isCallsPath(pathname)) return; + + guestSession.start(`${pathname}${search}`); +};