diff --git a/CONTEXT.md b/CONTEXT.md index 05c06620a..7589d5286 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,22 @@ _Avoid_: Website, property The mutable identity, visibility, tracking, exclusion, and feature settings attached to a Site. _Avoid_: Site settings, tracking config +**Organization Access Decision**: +The operation-specific determination of whether an actor may perform an Organization settings action, preserving active-Organization availability, Organization membership role, global role, and pending or error state. +_Avoid_: Settings guard, role check + +**Experiment Authoring**: +The ordered operation that resolves or creates an Experiment's assignment Feature Flag and primary Goal, then creates or updates the Experiment and produces its implementation state. +_Avoid_: Experiment form submission, experiment save flow + +**Dashboard Time Preset**: +A well-known named analytics time range whose selector membership and timezone-specific Time value come from the canonical preset groups. +_Avoid_: Date shortcut, time option + +**Replay Session**: +The client-owned playback lifecycle for one selected Session Replay, including player readiness, position, playback state, speed, activity periods, and visibility recovery. +_Avoid_: Replay state, player store + **Site Exclusion Decision**: The ordered determination that an ingestion request should not be recorded because it matches a Site Configuration exclusion rule. _Avoid_: Filter result, blocked event diff --git a/client/src/api/admin/endpoints/index.ts b/client/src/api/admin/endpoints/index.ts index fab3d5ed8..79d3dcc4d 100644 --- a/client/src/api/admin/endpoints/index.ts +++ b/client/src/api/admin/endpoints/index.ts @@ -10,7 +10,7 @@ export { fetchSiteIsPublic, fetchSiteUsage, } from "./sites"; -export type { SiteResponse, GetSitesFromOrgResponse, SiteUsageResponse } from "./sites"; +export type { SiteConfig, SiteResponse, GetSitesFromOrgResponse, SiteUsageResponse } from "./sites"; // Organizations endpoints export { diff --git a/client/src/api/admin/endpoints/sites.ts b/client/src/api/admin/endpoints/sites.ts index 5b31d7b0d..deaba7f6c 100644 --- a/client/src/api/admin/endpoints/sites.ts +++ b/client/src/api/admin/endpoints/sites.ts @@ -70,6 +70,36 @@ export type GetSitesFromOrgResponse = { }; }; +export type SiteConfig = { + name?: string; + type?: "web" | "mobile" | null; + domain?: string; + public?: boolean; + embedEnabled?: boolean; + saltUserIds?: boolean; + blockBots?: boolean; + firstPartyProxy?: boolean; + excludedIPs?: string[]; + excludedCountries?: string[]; + excludedPaths?: string[]; + excludedHostnames?: string[]; + excludedUserAgents?: string[]; + excludedASNs?: string[]; + excludedQueryParams?: string[]; + sessionReplay?: boolean; + webVitals?: boolean; + trackErrors?: boolean; + trackOutbound?: boolean; + trackUrlParams?: boolean; + trackInitialPageView?: boolean; + trackSpaNavigation?: boolean; + trackIp?: boolean; + trackButtonClicks?: boolean; + trackCopy?: boolean; + trackFormInteractions?: boolean; + tags?: string[]; +}; + export function fetchSitesFromOrg(organizationId: string) { return authedFetch(`/organizations/${organizationId}/sites`); } @@ -136,39 +166,7 @@ export function moveSite(siteId: number, organizationId: string) { }); } -// Consolidated function to update any site configuration -export function updateSiteConfig( - siteId: number, - config: { - name?: string; - type?: "web" | "mobile" | null; - domain?: string; - public?: boolean; - embedEnabled?: boolean; - saltUserIds?: boolean; - blockBots?: boolean; - firstPartyProxy?: boolean; - excludedIPs?: string[]; - excludedCountries?: string[]; - excludedPaths?: string[]; - excludedHostnames?: string[]; - excludedUserAgents?: string[]; - excludedASNs?: string[]; - excludedQueryParams?: string[]; - sessionReplay?: boolean; - webVitals?: boolean; - trackErrors?: boolean; - trackOutbound?: boolean; - trackUrlParams?: boolean; - trackInitialPageView?: boolean; - trackSpaNavigation?: boolean; - trackIp?: boolean; - trackButtonClicks?: boolean; - trackCopy?: boolean; - trackFormInteractions?: boolean; - tags?: string[]; - } -) { +export function updateSiteConfig(siteId: number, config: SiteConfig) { return authedFetch(`/sites/${siteId}/config`, undefined, { method: "PUT", data: config, diff --git a/client/src/api/admin/hooks/useSiteConfiguration.test.ts b/client/src/api/admin/hooks/useSiteConfiguration.test.ts new file mode 100644 index 000000000..0145f2b39 --- /dev/null +++ b/client/src/api/admin/hooks/useSiteConfiguration.test.ts @@ -0,0 +1,27 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { refreshSiteConfigurationSummaries } from "./useSiteConfiguration"; + +describe("refreshSiteConfigurationSummaries", () => { + it("invalidates every shared summary of Site Configuration", async () => { + const queryClient = new QueryClient(); + const affectedKeys = [ + ["get-site", 42], + ["get-site", "42"], + ["get-sites-from-org", "org-1"], + ["admin-organizations", { page: 1 }], + ] as const; + const unrelatedKey = ["site-has-data", "42"] as const; + + for (const queryKey of [...affectedKeys, unrelatedKey]) { + queryClient.setQueryData(queryKey, { cached: true }); + } + + await refreshSiteConfigurationSummaries(queryClient); + + for (const queryKey of affectedKeys) { + expect(queryClient.getQueryState(queryKey)?.isInvalidated, JSON.stringify(queryKey)).toBe(true); + } + expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBe(false); + }); +}); diff --git a/client/src/api/admin/hooks/useSiteConfiguration.ts b/client/src/api/admin/hooks/useSiteConfiguration.ts new file mode 100644 index 000000000..d36ac6f91 --- /dev/null +++ b/client/src/api/admin/hooks/useSiteConfiguration.ts @@ -0,0 +1,31 @@ +import { QueryClient, useMutation, useQueryClient } from "@tanstack/react-query"; +import { SiteConfig, updateSiteConfig } from "../endpoints/sites"; + +export type UpdateSiteConfigurationInput = { + siteId: number; + config: SiteConfig; +}; + +/** + * Refresh the shared Site summaries that project Site Configuration. Prefix + * matching keeps string- and number-keyed Site queries consistent; dedicated + * exclusion queries continue to refresh in their operation-specific hooks. + */ +export async function refreshSiteConfigurationSummaries(queryClient: QueryClient) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["get-site"] }), + queryClient.invalidateQueries({ queryKey: ["get-sites-from-org"] }), + queryClient.invalidateQueries({ queryKey: ["admin-organizations"] }), + ]); +} + +export function useUpdateSiteConfiguration() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ siteId, config }: UpdateSiteConfigurationInput) => updateSiteConfig(siteId, config), + onSuccess: () => { + void refreshSiteConfigurationSummaries(queryClient); + }, + }); +} diff --git a/client/src/app/[site]/errors/components/EnableErrorTracking.tsx b/client/src/app/[site]/errors/components/EnableErrorTracking.tsx index 9b0733188..cbeed28af 100644 --- a/client/src/app/[site]/errors/components/EnableErrorTracking.tsx +++ b/client/src/app/[site]/errors/components/EnableErrorTracking.tsx @@ -4,7 +4,7 @@ import { useExtracted } from "next-intl"; import { AlertTriangle } from "lucide-react"; import { useParams } from "next/navigation"; import { toast } from "@/components/ui/sonner"; -import { updateSiteConfig } from "../../../../api/admin/endpoints"; +import { useUpdateSiteConfiguration } from "../../../../api/admin/hooks/useSiteConfiguration"; import { useGetSite } from "../../../../api/admin/hooks/useSites"; import { Alert, AlertDescription, AlertTitle } from "../../../../components/ui/alert"; import { Button } from "../../../../components/ui/button"; @@ -13,7 +13,8 @@ export function EnableErrorTracking() { const t = useExtracted(); const params = useParams(); const siteId = Number(params.site); - const { data: siteMetadata, refetch } = useGetSite(siteId); + const { data: siteMetadata } = useGetSite(siteId); + const { mutateAsync: updateSiteConfiguration } = useUpdateSiteConfiguration(); if (siteMetadata?.trackErrors) return null; @@ -27,15 +28,15 @@ export function EnableErrorTracking() {
- {t("Error tracking captures JavaScript errors and exceptions from your application.")} {t("Note:")} {t("Enabling error tracking will increase your event usage.")} + {t("Error tracking captures JavaScript errors and exceptions from your application.")} {t("Note:")}{" "} + {t("Enabling error tracking will increase your event usage.")}
+ + ); + } + + return ( +
+ {deniedMessage} +
+ ); +} diff --git a/client/src/app/settings/organization/layout.tsx b/client/src/app/settings/organization/layout.tsx index d1379541a..694218c50 100644 --- a/client/src/app/settings/organization/layout.tsx +++ b/client/src/app/settings/organization/layout.tsx @@ -6,18 +6,14 @@ import { useState } from "react"; import { CreateOrganizationDialog } from "../../../components/CreateOrganizationDialog"; import { OrganizationSelector } from "../../../components/OrganizationSelector"; import { Button } from "../../../components/ui/button"; -import { authClient } from "../../../lib/auth"; +import { useOrganizationAccess } from "../../../hooks/useOrganizationAccess"; +import { OrganizationAccessGate } from "../components/OrganizationAccessGate"; export default function OrganizationLayout({ children }: { children: React.ReactNode }) { const [createOrgDialogOpen, setCreateOrgDialogOpen] = useState(false); const t = useExtracted(); - const { data: session } = authClient.useSession(); - const { data: activeOrg } = authClient.useActiveOrganization(); - const currentMember = activeOrg?.members?.find( - (m) => m.userId === session?.user?.id - ); - const isMember = currentMember?.role === "member"; + const access = useOrganizationAccess(); return ( <> @@ -40,15 +36,17 @@ export default function OrganizationLayout({ children }: { children: React.React /> - {isMember ? ( -
- {t("You don't have permission to view organization settings.")} -
- ) : ( - <> -
{children}
- - )} + +
{children}
+
); diff --git a/client/src/app/settings/teams/layout.tsx b/client/src/app/settings/teams/layout.tsx index db834f00d..25fa88d7a 100644 --- a/client/src/app/settings/teams/layout.tsx +++ b/client/src/app/settings/teams/layout.tsx @@ -3,18 +3,14 @@ import { Plus } from "lucide-react"; import { useExtracted } from "next-intl"; import { Button } from "../../../components/ui/button"; -import { authClient } from "../../../lib/auth"; +import { useOrganizationAccess } from "../../../hooks/useOrganizationAccess"; import { CreateEditTeamDialog } from "./components/CreateEditTeamDialog"; import { ExternalLink } from "../../../components/ExternalLink"; +import { OrganizationAccessGate } from "../components/OrganizationAccessGate"; export default function TeamsLayout({ children }: { children: React.ReactNode }) { const t = useExtracted(); - const { data: session } = authClient.useSession(); - const { data: activeOrg } = authClient.useActiveOrganization(); - const currentMember = activeOrg?.members?.find( - (m) => m.userId === session?.user?.id - ); - const isMember = currentMember?.role === "member"; + const access = useOrganizationAccess(); return (
@@ -23,12 +19,10 @@ export default function TeamsLayout({ children }: { children: React.ReactNode })

{t("Teams")}

{t("Organize sites into teams to control which members can access them.")} - - {t("Learn more about teams")} - + {t("Learn more about teams")}

- {activeOrg?.id && !isMember && ( + {access.decisions.manageTeams.allowed && ( @@ -40,13 +34,17 @@ export default function TeamsLayout({ children }: { children: React.ReactNode }) )} - {isMember ? ( -
- {t("You don't have permission to view team settings.")} -
- ) : ( +
{children}
- )} +
); } diff --git a/client/src/components/DateSelector/RangePanel.tsx b/client/src/components/DateSelector/RangePanel.tsx index 9aa49ce4b..eb35ee521 100644 --- a/client/src/components/DateSelector/RangePanel.tsx +++ b/client/src/components/DateSelector/RangePanel.tsx @@ -15,7 +15,11 @@ import useMediaQuery from "@/components/ui/hooks/useMediaQuery"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { timezones } from "@/lib/dateTimeUtils"; -import { getDashboardTimeForRange, type DashboardDefaultTimeRange } from "@/lib/defaultTimeRange"; +import { + DASHBOARD_TIME_PRESET_GROUPS, + getDashboardTimeForRange, + type DashboardDefaultTimeRange, +} from "@/lib/defaultTimeRange"; import { deriveTimeState, getAbsoluteBounds } from "@/lib/time"; import { cn } from "@/lib/utils"; import { DateTime } from "luxon"; @@ -23,7 +27,7 @@ import { Check, Globe } from "lucide-react"; import { useExtracted } from "next-intl"; import { Fragment, useState } from "react"; import { DateRange } from "react-day-picker"; -import { PRESET_GROUPS, usePresetLabels } from "./presets"; +import { usePresetLabels } from "./presets"; import { describeBounds, rangeFieldsForTime, @@ -118,7 +122,7 @@ export function RangePanel({ return { time: next, fields: rangeFieldsForTime(next, zone) }; }); - const groups = PRESET_GROUPS.filter(group => pastMinutesEnabled || group.id !== "realtime"); + const groups = DASHBOARD_TIME_PRESET_GROUPS.filter(group => pastMinutesEnabled || !group.pastMinutesOnly); const bounds = getAbsoluteBounds(draft.time, zone); const selected: DateRange | undefined = bounds @@ -154,10 +158,10 @@ export function RangePanel({ their contents, and cmdk drops the rules automatically while a search is filtering across them. */} {groups.map((group, index) => ( - + {index > 0 && } - {group.presets.map(preset => ( + {group.ranges.map(preset => ( void; + resetPlaybackOnClose?: boolean; } -export function ReplayDrawer({ sessionId, open, onOpenChange }: ReplayDrawerProps) { - const { setSessionId, resetPlayerState } = useReplayStore(); +export function ReplayDrawer({ sessionId, open, onOpenChange, resetPlaybackOnClose = true }: ReplayDrawerProps) { + const { openSession, resetPlayback } = useReplayStore(); const containerRef = useRef(null); + const wasOpenRef = useRef(open); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); // Set sessionId in store when drawer opens useEffect(() => { if (open && sessionId) { - setSessionId(sessionId); + openSession(sessionId); } - }, [open, sessionId, setSessionId]); + }, [open, sessionId, openSession]); - // Reset player state when drawer closes + // Standalone drawers restart when closed. The fullscreen drawer opts out so + // disconnecting it restores the underlying Replay Session player in place. useEffect(() => { - if (!open) { - resetPlayerState(); + if (resetPlaybackOnClose && wasOpenRef.current && !open) { + resetPlayback(); } - }, [open, resetPlayerState]); + wasOpenRef.current = open; + }, [open, resetPlayback, resetPlaybackOnClose]); // Measure container dimensions using getBoundingClientRect for more reliable sizing useEffect(() => { diff --git a/client/src/components/SiteSettings/GeneralTab.tsx b/client/src/components/SiteSettings/GeneralTab.tsx index 16f6374c9..465035e0c 100644 --- a/client/src/components/SiteSettings/GeneralTab.tsx +++ b/client/src/components/SiteSettings/GeneralTab.tsx @@ -24,8 +24,9 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { deleteSite, moveSite, updateSiteConfig, SiteResponse } from "@/api/admin/endpoints"; +import { deleteSite, moveSite, SiteResponse } from "@/api/admin/endpoints"; import { adminMoveSite } from "@/api/admin/endpoints/adminSites"; +import { useUpdateSiteConfiguration } from "@/api/admin/hooks/useSiteConfiguration"; import { useUserOrganizations } from "@/api/admin/hooks/useOrganizations"; import { useGetSitesFromOrg } from "@/api/admin/hooks/useSites"; import { RemoteOrganizationCombobox } from "@/app/admin/components/shared/RemoteOrganizationCombobox"; @@ -62,6 +63,7 @@ export function GeneralTab({ }: GeneralTabProps) { const t = useExtracted(); const { refetch } = useGetSitesFromOrg(siteMetadata?.organizationId ?? "", { enabled: !adminMode }); + const { mutateAsync: updateSiteConfiguration } = useUpdateSiteConfiguration(); const { data: userOrganizations } = useUserOrganizations(); const queryClient = useQueryClient(); const router = useRouter(); @@ -108,7 +110,7 @@ export function GeneralTab({ ) => { setLoadingStates(prev => ({ ...prev, [key]: true })); try { - await updateSiteConfig(siteMetadata.siteId, { [key]: checked }); + await updateSiteConfiguration({ siteId: siteMetadata.siteId, config: { [key]: checked } }); setToggleStates(prev => ({ ...prev, [key]: checked })); if (key === "public") { onPublicChange?.(checked); @@ -119,7 +121,6 @@ export function GeneralTab({ : successMessage.disabled : `${key.replace(/([A-Z])/g, " $1").toLowerCase()} ${checked ? "enabled" : "disabled"}`; toast.success(message); - refreshSiteLists(); } catch (error) { console.error(`Error updating ${key}:`, error); toast.error(`Failed to update ${key.replace(/([A-Z])/g, " $1").toLowerCase()}`); @@ -128,7 +129,7 @@ export function GeneralTab({ setLoadingStates(prev => ({ ...prev, [key]: false })); } }, - [siteMetadata.siteId, onPublicChange, refreshSiteLists] + [siteMetadata.siteId, onPublicChange, updateSiteConfiguration] ); const handleNameChange = async () => { @@ -139,10 +140,9 @@ export function GeneralTab({ try { setIsChangingName(true); - await updateSiteConfig(siteMetadata.siteId, { name: newName.trim() }); + await updateSiteConfiguration({ siteId: siteMetadata.siteId, config: { name: newName.trim() } }); toast.success(t("Name updated successfully")); router.refresh(); - refreshSiteLists(); } catch (error) { console.error("Error changing name:", error); toast.error(t("Failed to update name")); @@ -160,10 +160,9 @@ export function GeneralTab({ try { setIsChangingDomain(true); const normalizedDomain = isMobileSite ? newDomain.trim() : normalizeDomain(newDomain); - await updateSiteConfig(siteMetadata.siteId, { domain: normalizedDomain }); + await updateSiteConfiguration({ siteId: siteMetadata.siteId, config: { domain: normalizedDomain } }); toast.success(isMobileSite ? t("App identifier updated successfully") : t("Domain updated successfully")); router.refresh(); - refreshSiteLists(); } catch (error) { console.error("Error changing domain:", error); toast.error(t("Failed to update domain")); diff --git a/client/src/components/SiteSettings/SiteSettings.tsx b/client/src/components/SiteSettings/SiteSettings.tsx index 7d51a5907..0f85b6382 100644 --- a/client/src/components/SiteSettings/SiteSettings.tsx +++ b/client/src/components/SiteSettings/SiteSettings.tsx @@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import { toast } from "@/components/ui/sonner"; -import { authClient } from "@/lib/auth"; +import { useOrganizationAccess } from "@/hooks/useOrganizationAccess"; import { cn } from "@/lib/utils"; import { ScriptBuilder } from "./ScriptBuilder"; @@ -32,9 +32,8 @@ import { EmbedTab } from "./EmbedTab"; import { DashboardEmbedTab } from "./DashboardEmbedTab"; import { UsageTab } from "./UsageTab"; import { useGetSite } from "../../api/admin/hooks/useSites"; -import { useUserOrganizations } from "../../api/admin/hooks/useOrganizations"; -import { useGetSitesFromOrg } from "../../api/admin/hooks/useSites"; -import { SiteResponse, updateSiteConfig } from "../../api/admin/endpoints"; +import { useUpdateSiteConfiguration } from "../../api/admin/hooks/useSiteConfiguration"; +import { SiteResponse } from "../../api/admin/endpoints"; import { IS_CLOUD } from "../../lib/const"; interface SiteSettingsProps { @@ -102,10 +101,8 @@ function SiteSettingsInner({ initialOpen?: boolean; }) { const t = useExtracted(); - const { data: session } = authClient.useSession(); - const { data: userOrganizationsData } = useUserOrganizations(); - const siteOrgMembership = userOrganizationsData?.find(org => org.id === siteMetadata.organizationId); - const disabled = session?.user.role !== "admin" && (!siteOrgMembership?.role || siteOrgMembership.role === "member"); + const organizationAccess = useOrganizationAccess(siteMetadata.organizationId); + const disabled = !organizationAccess.decisions.manageSiteConfiguration.allowed; const [dialogOpen, setDialogOpen] = useState(initialOpen); const [activeTab, setActiveTab] = useState("general"); @@ -113,9 +110,7 @@ function SiteSettingsInner({ const [togglingEmbed, setTogglingEmbed] = useState(false); const [sitePublic, setSitePublic] = useState(!!siteMetadata.public); const adminMode = !!adminOrganization; - const { refetch: refetchOrgSites } = useGetSitesFromOrg(siteMetadata?.organizationId ?? "", { - enabled: !adminMode, - }); + const { mutateAsync: updateSiteConfiguration } = useUpdateSiteConfiguration(); useEffect(() => { setEmbedEnabled(!!siteMetadata.embedEnabled); @@ -126,12 +121,9 @@ function SiteSettingsInner({ async (checked: boolean) => { setTogglingEmbed(true); try { - await updateSiteConfig(siteMetadata.siteId, { embedEnabled: checked }); + await updateSiteConfiguration({ siteId: siteMetadata.siteId, config: { embedEnabled: checked } }); setEmbedEnabled(checked); toast.success(checked ? t("Embed widget enabled") : t("Embed widget disabled")); - if (!adminMode) { - refetchOrgSites(); - } } catch (error) { console.error("Error toggling embed:", error); toast.error(t("Failed to update embed setting")); @@ -139,7 +131,7 @@ function SiteSettingsInner({ setTogglingEmbed(false); } }, - [siteMetadata.siteId, refetchOrgSites, t, adminMode] + [siteMetadata.siteId, t, updateSiteConfiguration] ); if (!siteMetadata) { diff --git a/client/src/components/SiteSettings/TrackingTab.tsx b/client/src/components/SiteSettings/TrackingTab.tsx index b82efd3d5..89011b949 100644 --- a/client/src/components/SiteSettings/TrackingTab.tsx +++ b/client/src/components/SiteSettings/TrackingTab.tsx @@ -1,15 +1,14 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useExtracted } from "next-intl"; import { useState, useCallback, ReactNode } from "react"; import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { updateSiteConfig, SiteResponse } from "@/api/admin/endpoints"; -import { useGetSitesFromOrg } from "@/api/admin/hooks/useSites"; -import { planIncludesReplay } from "@/lib/subscription/planUtils"; +import { SiteResponse } from "@/api/admin/endpoints"; +import { useUpdateSiteConfiguration } from "@/api/admin/hooks/useSiteConfiguration"; +import { planIncludesReplay, planIncludesStandardFeatures } from "@/lib/subscription/planUtils"; import { useStripeSubscription } from "@/lib/subscription/useStripeSubscription"; import { Badge } from "@/components/ui/badge"; import { IS_CLOUD } from "@/lib/const"; @@ -42,8 +41,7 @@ export function TrackingTab({ adminMode = false, }: TrackingTabProps) { const t = useExtracted(); - const queryClient = useQueryClient(); - const { refetch } = useGetSitesFromOrg(siteMetadata?.organizationId ?? "", { enabled: !adminMode }); + const { mutateAsync: updateSiteConfiguration } = useUpdateSiteConfiguration(); const isMobileSite = siteMetadata.type === "mobile"; const [toggleStates, setToggleStates] = useState({ @@ -69,7 +67,7 @@ export function TrackingTab({ ) => { setLoadingStates(prev => ({ ...prev, [key]: true })); try { - await updateSiteConfig(siteMetadata.siteId, { [key]: checked }); + await updateSiteConfiguration({ siteId: siteMetadata.siteId, config: { [key]: checked } }); setToggleStates(prev => ({ ...prev, [key]: checked })); const message = successMessage ? checked @@ -77,13 +75,6 @@ export function TrackingTab({ : successMessage.disabled : `${key.replace(/([A-Z])/g, " $1").toLowerCase()} ${checked ? "enabled" : "disabled"}`; toast.success(message); - if (!adminMode) { - refetch(); - } else { - queryClient.invalidateQueries({ queryKey: ["admin-organizations"] }); - } - // Prefix match so both string- and number-keyed useGetSite instances update - queryClient.invalidateQueries({ queryKey: ["get-site"] }); } catch (error) { console.error(`Error updating ${key}:`, error); toast.error(`Failed to update ${key.replace(/([A-Z])/g, " $1").toLowerCase()}`); @@ -92,7 +83,7 @@ export function TrackingTab({ setLoadingStates(prev => ({ ...prev, [key]: false })); } }, - [siteMetadata.siteId, refetch, queryClient, adminMode] + [siteMetadata.siteId, updateSiteConfiguration] ); const { data: subscription, isLoading: isSubscriptionLoading } = useStripeSubscription(); @@ -101,12 +92,7 @@ export function TrackingTab({ const sessionReplayDisabled = !planIncludesReplay(effectiveSubscription) && IS_CLOUD; - const standardFeaturesDisabled = - !effectiveSubscription?.planName.includes("custom") && - !effectiveSubscription?.planName.includes("standard") && - !effectiveSubscription?.planName.includes("pro") && - !effectiveSubscription?.planName.includes("appsumo") && - IS_CLOUD; + const standardFeaturesDisabled = !planIncludesStandardFeatures(effectiveSubscription) && IS_CLOUD; const analyticsToggles: ToggleConfig[] = [ // Hide the replay toggle for AppSumo tiers without replays (1-3); tiers 4-7 include them diff --git a/client/src/components/TagEditor.tsx b/client/src/components/TagEditor.tsx index ddb95dae9..a3ff38b1a 100644 --- a/client/src/components/TagEditor.tsx +++ b/client/src/components/TagEditor.tsx @@ -3,7 +3,7 @@ import { Plus, X } from "lucide-react"; import { useExtracted } from "next-intl"; import { ReactNode, useState } from "react"; -import { updateSiteConfig } from "../api/admin/endpoints/sites"; +import { useUpdateSiteConfiguration } from "../api/admin/hooks/useSiteConfiguration"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { @@ -31,6 +31,7 @@ export function TagEditor({ siteId, currentTags, allTags, onTagsUpdated, trigger const [tags, setTags] = useState(currentTags); const [newTagInput, setNewTagInput] = useState(""); const [isSaving, setIsSaving] = useState(false); + const { mutateAsync: updateSiteConfiguration } = useUpdateSiteConfiguration(); // Get suggestions - existing tags that aren't already selected const suggestions = allTags.filter(tag => !tags.includes(tag)); @@ -57,7 +58,7 @@ export function TagEditor({ siteId, currentTags, allTags, onTagsUpdated, trigger const handleSave = async () => { setIsSaving(true); try { - await updateSiteConfig(siteId, { tags }); + await updateSiteConfiguration({ siteId, config: { tags } }); onTagsUpdated(); setOpen(false); } catch (error) { @@ -120,11 +121,7 @@ export function TagEditor({ siteId, currentTags, allTags, onTagsUpdated, trigger onKeyDown={handleKeyDown} maxLength={50} /> - diff --git a/client/src/components/replay/ReplayBreadcrumbs.tsx b/client/src/components/replay/ReplayBreadcrumbs.tsx index 876ea260c..51043562f 100644 --- a/client/src/components/replay/ReplayBreadcrumbs.tsx +++ b/client/src/components/replay/ReplayBreadcrumbs.tsx @@ -170,11 +170,11 @@ export function ReplayBreadcrumbs() { const params = useParams(); const siteId = Number(params.site); const [showTechnical, setShowTechnical] = useState(false); - const { sessionId, player, setCurrentTime } = useReplayStore( + const { sessionId, playerReady, seekTo } = useReplayStore( useShallow(s => ({ sessionId: s.sessionId, - player: s.player, - setCurrentTime: s.setCurrentTime, + playerReady: s.playerReady, + seekTo: s.seekTo, })) ); @@ -185,11 +185,10 @@ export function ReplayBreadcrumbs() { const handleSeek = useCallback( (offset: number) => { - if (!player) return; - player.goto(offset); - setCurrentTime(offset); + if (!playerReady) return; + seekTo(offset); }, - [player, setCurrentTime] + [playerReady, seekTo] ); // Resolve labels here, where `t` is the real useExtracted() binding, so the @@ -472,4 +471,3 @@ function TechnicalRow({ ); } - diff --git a/client/src/components/replay/player/ReplayPlayer.tsx b/client/src/components/replay/player/ReplayPlayer.tsx index 09c860bfc..23936fc73 100644 --- a/client/src/components/replay/player/ReplayPlayer.tsx +++ b/client/src/components/replay/player/ReplayPlayer.tsx @@ -1,109 +1,34 @@ import { useParams } from "next/navigation"; -import { useCallback, useEffect } from "react"; import "rrweb-player/dist/style.css"; import { useShallow } from "zustand/react/shallow"; import { useGetSessionReplayEvents } from "@/api/analytics/hooks/sessionReplay/useGetSessionReplayEvents"; import { ThreeDotLoader } from "@/components/Loaders"; import { useReplayStore } from "../replayStore"; -import { useActivityPeriods } from "./hooks/useActivityPeriods"; import { useReplayKeyboardShortcuts } from "./hooks/useReplayKeyboardShortcuts"; import { ReplayPlayerControls } from "./ReplayPlayerControls"; import { ReplayPlayerCore } from "./ReplayPlayerCore"; -import { SKIP_SECONDS } from "./utils/replayUtils"; import { ReplayPlayerTopbar } from "./ReplayPlayerTopbar"; export function ReplayPlayer({ width, height, isDrawer }: { width: number; height: number; isDrawer?: boolean }) { const params = useParams(); const siteId = Number(params.site); - const { - sessionId, - player, - isPlaying, - setIsPlaying, - currentTime, - setCurrentTime, - duration, - setPlaybackSpeed, - resetPlayerState, - } = useReplayStore( + const { sessionId, playerReady, togglePlayback, skipBackward, skipForward } = useReplayStore( useShallow(s => ({ sessionId: s.sessionId, - player: s.player, - isPlaying: s.isPlaying, - setIsPlaying: s.setIsPlaying, - currentTime: s.currentTime, - setCurrentTime: s.setCurrentTime, - duration: s.duration, - setPlaybackSpeed: s.setPlaybackSpeed, - resetPlayerState: s.resetPlayerState, + playerReady: s.playerReady, + togglePlayback: s.togglePlayback, + skipBackward: s.skipBackward, + skipForward: s.skipForward, })) ); const { data, isLoading, error } = useGetSessionReplayEvents(siteId, sessionId); - // Reset player state when session changes - useEffect(() => { - resetPlayerState(); - }, [sessionId, resetPlayerState]); - - // Calculate activity periods when player and data are ready - useActivityPeriods({ data, player }); - - const handlePlayPause = useCallback(() => { - if (!player) return; - - const newPlayingState = !isPlaying; - - if (isPlaying) { - player.pause(); - } else { - player.play(); - } - setIsPlaying(newPlayingState); - }, [player, isPlaying, setIsPlaying]); - - const handleSkipBack = useCallback(() => { - if (!player) return; - const newTime = Math.max(0, currentTime - SKIP_SECONDS); - player.goto(newTime); - }, [player, currentTime]); - - const handleSkipForward = useCallback(() => { - if (!player) return; - const newTime = Math.min(duration, currentTime + SKIP_SECONDS); - player.goto(newTime); - }, [player, duration, currentTime]); - - const handleSliderChange = useCallback( - (value: number[]) => { - if (!player || !duration) return; - - // Pause the player when user scrubs manually - player.pause(); - setIsPlaying(false); - - const newTime = (value[0] / 100) * duration; - player.goto(newTime); - setCurrentTime(newTime); - }, - [player, duration, setIsPlaying, setCurrentTime] - ); - - const handleSpeedChange = useCallback( - (speed: string) => { - if (!player) return; - setPlaybackSpeed(speed); - player.setSpeed(parseFloat(speed)); - }, - [player, setPlaybackSpeed] - ); - - // Add keyboard shortcuts useReplayKeyboardShortcuts({ - player, - onSkipBack: handleSkipBack, - onSkipForward: handleSkipForward, - onPlayPause: handlePlayPause, + enabled: playerReady, + onSkipBack: skipBackward, + onSkipForward: skipForward, + onPlayPause: togglePlayback, }); if (error) { @@ -123,21 +48,9 @@ export function ReplayPlayer({ width, height, isDrawer }: { width: number; heigh {isLoading || !data ? ( ) : ( - + )} - + ); } diff --git a/client/src/components/replay/player/ReplayPlayerControls.tsx b/client/src/components/replay/player/ReplayPlayerControls.tsx index d3c071601..30b66af1c 100644 --- a/client/src/components/replay/player/ReplayPlayerControls.tsx +++ b/client/src/components/replay/player/ReplayPlayerControls.tsx @@ -1,3 +1,4 @@ +import type { SessionReplayEvent } from "@/api/analytics/endpoints"; import { ActivitySlider } from "@/components/ui/activity-slider"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -10,29 +11,37 @@ import { useReplayStore } from "../replayStore"; import { formatTime, PLAYBACK_SPEEDS } from "./utils/replayUtils"; interface ReplayPlayerControlsProps { - events: any[]; - onPlayPause: () => void; - onSliderChange: (value: number[]) => void; - onSpeedChange: (speed: string) => void; + events: SessionReplayEvent[]; isDrawer?: boolean; } export const ReplayPlayerControls = memo(function ReplayPlayerControls({ events, - onPlayPause, - onSliderChange, - onSpeedChange, isDrawer, }: ReplayPlayerControlsProps) { - const { sessionId, player, isPlaying, currentTime, duration, playbackSpeed, activityPeriods } = useReplayStore( + const { + sessionId, + playerReady, + isPlaying, + currentTime, + duration, + playbackSpeed, + activityPeriods, + togglePlayback, + scrubTo, + changePlaybackSpeed, + } = useReplayStore( useShallow(s => ({ sessionId: s.sessionId, - player: s.player, + playerReady: s.playerReady, isPlaying: s.isPlaying, currentTime: s.currentTime, duration: s.duration, playbackSpeed: s.playbackSpeed, activityPeriods: s.activityPeriods, + togglePlayback: s.togglePlayback, + scrubTo: s.scrubTo, + changePlaybackSpeed: s.changePlaybackSpeed, })) ); const [replayDrawerOpen, setReplayDrawerOpen] = useState(false); @@ -40,7 +49,7 @@ export const ReplayPlayerControls = memo(function ReplayPlayerControls({ return (
- + <> + + + )} -
); diff --git a/client/src/components/replay/player/ReplayPlayerCore.tsx b/client/src/components/replay/player/ReplayPlayerCore.tsx index a2912297e..21cf8ffda 100644 --- a/client/src/components/replay/player/ReplayPlayerCore.tsx +++ b/client/src/components/replay/player/ReplayPlayerCore.tsx @@ -1,16 +1,21 @@ +import type { SessionReplayEvent } from "@/api/analytics/endpoints"; +import { useShallow } from "zustand/react/shallow"; + +import { useReplayStore } from "../replayStore"; import { useReplayPlayer } from "./hooks/useReplayPlayer"; import { ReplayPlayerOverlay } from "./ReplayPlayerOverlay"; interface ReplayPlayerCoreProps { - data: { events: any[] } | undefined; + data: { events: SessionReplayEvent[] } | undefined; width: number; height: number; - onPlayPause: () => void; - isPlaying: boolean; } -export function ReplayPlayerCore({ data, width, height, onPlayPause, isPlaying }: ReplayPlayerCoreProps) { +export function ReplayPlayerCore({ data, width, height }: ReplayPlayerCoreProps) { const { playerContainerRef } = useReplayPlayer({ data, width, height }); + const { isPlaying, togglePlayback } = useReplayStore( + useShallow(state => ({ isPlaying: state.isPlaying, togglePlayback: state.togglePlayback })) + ); return (
@@ -22,7 +27,7 @@ export function ReplayPlayerCore({ data, width, height, onPlayPause, isPlaying } }} /> - +
); } diff --git a/client/src/components/replay/player/hooks/useActivityPeriods.ts b/client/src/components/replay/player/hooks/useActivityPeriods.ts deleted file mode 100644 index 9ce9c4b06..000000000 --- a/client/src/components/replay/player/hooks/useActivityPeriods.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect } from "react"; -import { useReplayStore } from "../../replayStore"; -import { calculateActivityPeriods } from "../utils/replayUtils"; - -interface UseActivityPeriodsProps { - data: { events: any[] } | undefined; - player: any; -} - -export const useActivityPeriods = ({ data, player }: UseActivityPeriodsProps) => { - const { setActivityPeriods } = useReplayStore(); - - useEffect(() => { - if (!data?.events || !player) return; - - // Calculate activity periods after we have duration - const timeoutId = setTimeout(() => { - if (!data.events || data.events.length === 0) return; - - const totalDuration = player.getMetaData().totalTime || 0; - const periods = calculateActivityPeriods(data.events, totalDuration); - setActivityPeriods(periods); - }, 150); // Run after duration is set - - return () => clearTimeout(timeoutId); - }, [data, player, setActivityPeriods]); -}; diff --git a/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.test.tsx b/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.test.tsx new file mode 100644 index 000000000..161530f24 --- /dev/null +++ b/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.test.tsx @@ -0,0 +1,43 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useReplayKeyboardShortcuts } from "./useReplayKeyboardShortcuts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useReplayKeyboardShortcuts", () => { + it("gives stacked shortcuts to the most recently mounted replay player", () => { + const pageToggle = vi.fn(); + const drawerToggle = vi.fn(); + const noOp = vi.fn(); + const page = renderHook(() => + useReplayKeyboardShortcuts({ + enabled: true, + onSkipBack: noOp, + onSkipForward: noOp, + onPlayPause: pageToggle, + }) + ); + const drawer = renderHook(() => + useReplayKeyboardShortcuts({ + enabled: true, + onSkipBack: noOp, + onSkipForward: noOp, + onPlayPause: drawerToggle, + }) + ); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + + expect(drawerToggle).toHaveBeenCalledOnce(); + expect(pageToggle).not.toHaveBeenCalled(); + + drawer.unmount(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + + expect(pageToggle).toHaveBeenCalledOnce(); + page.unmount(); + }); +}); diff --git a/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.ts b/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.ts index 02e796bcd..bdc1c057d 100644 --- a/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.ts +++ b/client/src/components/replay/player/hooks/useReplayKeyboardShortcuts.ts @@ -1,22 +1,34 @@ import { useEffect } from "react"; interface UseReplayKeyboardShortcutsProps { - player: any; + enabled: boolean; onSkipBack: () => void; onSkipForward: () => void; onPlayPause: () => void; } +// Replay players can stack when the fullscreen drawer opens over the page +// player. Only the most recently mounted player should own document shortcuts. +const keyboardShortcutOwners: symbol[] = []; + export const useReplayKeyboardShortcuts = ({ - player, + enabled, onSkipBack, onSkipForward, onPlayPause, }: UseReplayKeyboardShortcutsProps) => { useEffect(() => { + if (!enabled) return; + + const owner = Symbol("replay-keyboard-shortcuts"); + keyboardShortcutOwners.push(owner); + const handleKeyDown = (event: KeyboardEvent) => { - // Only handle hotkeys when the player exists and focus is not on an input/textarea - if (!player || event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + if ( + keyboardShortcutOwners.at(-1) !== owner || + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { return; } @@ -40,6 +52,8 @@ export const useReplayKeyboardShortcuts = ({ return () => { document.removeEventListener("keydown", handleKeyDown); + const ownerIndex = keyboardShortcutOwners.lastIndexOf(owner); + if (ownerIndex !== -1) keyboardShortcutOwners.splice(ownerIndex, 1); }; - }, [player, onSkipBack, onSkipForward, onPlayPause]); + }, [enabled, onSkipBack, onSkipForward, onPlayPause]); }; diff --git a/client/src/components/replay/player/hooks/useReplayPlayer.ts b/client/src/components/replay/player/hooks/useReplayPlayer.ts index a9c77c5e2..5edbf2eea 100644 --- a/client/src/components/replay/player/hooks/useReplayPlayer.ts +++ b/client/src/components/replay/player/hooks/useReplayPlayer.ts @@ -1,154 +1,137 @@ +import type { SessionReplayEvent } from "@/api/analytics/endpoints"; import { useEffect, useRef } from "react"; import rrwebPlayer from "rrweb-player"; import { useShallow } from "zustand/react/shallow"; -import { useReplayStore } from "../../replayStore"; + +import { type ReplayPlayerAdapter, type ReplayPlayerUpdate, useReplayStore } from "../../replayStore"; import { CONTROLS_HEIGHT } from "../utils/replayUtils"; interface UseReplayPlayerProps { - data: { events: any[] } | undefined; + data: { events: SessionReplayEvent[] } | undefined; width: number; height: number; } +interface RrwebPlayerEvent { + payload?: unknown; +} + +interface RrwebPlayerInstance { + $set: (dimensions: { width: number; height: number }) => void; + addEventListener: (event: string, listener: (event: RrwebPlayerEvent) => void) => void; + getMetaData: () => { totalTime?: number }; + goto: (time: number) => void; + pause: () => void; + play: () => void; + setSpeed: (speed: number) => void; + triggerResize: () => void; +} + +function numericPayload(event: RrwebPlayerEvent): number | undefined { + return typeof event.payload === "number" && Number.isFinite(event.payload) ? event.payload : undefined; +} + +function createPlayerAdapter(player: RrwebPlayerInstance): ReplayPlayerAdapter { + return { + play: () => player.play(), + pause: () => player.pause(), + seek: time => player.goto(time), + setSpeed: speed => player.setSpeed(speed), + getDuration: () => player.getMetaData().totalTime ?? 0, + subscribe: listener => { + let subscribed = true; + const emit = (update: ReplayPlayerUpdate) => { + if (subscribed) listener(update); + }; + + player.addEventListener("ui-update-current-time", event => { + const value = numericPayload(event); + if (value !== undefined) emit({ type: "current-time", value }); + }); + player.addEventListener("ui-update-player-state", event => { + if (event.payload === "playing" || event.payload === "paused") { + emit({ type: "playback-state", value: event.payload }); + } + }); + player.addEventListener("ui-update-duration", event => { + const value = numericPayload(event); + if (value !== undefined) emit({ type: "duration", value }); + }); + + // rrweb can initialize its metadata after its first duration event, so + // perform one delayed synchronization through the same Adapter stream. + const durationTimeout = window.setTimeout(() => { + const duration = player.getMetaData().totalTime; + if (duration) emit({ type: "duration", value: duration }); + }, 100); + + return () => { + subscribed = false; + window.clearTimeout(durationTimeout); + }; + }, + }; +} + export const useReplayPlayer = ({ data, width, height }: UseReplayPlayerProps) => { const playerContainerRef = useRef(null); - const playerRef = useRef(null); - const { setPlayer, setCurrentTime, setIsPlaying, setDuration } = useReplayStore( - useShallow(s => ({ - setPlayer: s.setPlayer, - setCurrentTime: s.setCurrentTime, - setIsPlaying: s.setIsPlaying, - setDuration: s.setDuration, + const playerRef = useRef(null); + const { connectPlayer, setPlayerVisibility } = useReplayStore( + useShallow(state => ({ + connectPlayer: state.connectPlayer, + setPlayerVisibility: state.setPlayerVisibility, })) ); - // Store width/height in refs for the resize effect const widthRef = useRef(width); const heightRef = useRef(height); widthRef.current = width; heightRef.current = height; - // Initialize player when data changes useEffect(() => { - if (data?.events && playerContainerRef.current) { - // Clear any existing content first - playerContainerRef.current.innerHTML = ""; - - let newPlayer: any = null; - let handleVisibilityChange: (() => void) | null = null; - - try { - // Initialize rrweb player - newPlayer = new rrwebPlayer({ - target: playerContainerRef.current, - props: { - events: data.events as any, // Cast to any to handle type compatibility with rrweb - width: widthRef.current, - // subtract for the custom controls - height: heightRef.current - CONTROLS_HEIGHT, - autoPlay: false, - showController: false, // We'll use custom controls - }, - }); - - playerRef.current = newPlayer; - setPlayer(newPlayer); - - // Set up event listeners - newPlayer.addEventListener("ui-update-current-time", (event: any) => { - // Validate that current time doesn't exceed duration - const currentTime = event.payload; - const playerDuration = newPlayer.getMetaData().totalTime; - - if (playerDuration && currentTime > playerDuration) { - // If we've exceeded duration, pause and set to end - newPlayer.pause(); - setCurrentTime(playerDuration); - setIsPlaying(false); - } else { - setCurrentTime(currentTime); - } - }); - - newPlayer.addEventListener("ui-update-player-state", (event: any) => { - setIsPlaying(event.payload === "playing"); - }); - - newPlayer.addEventListener("ui-update-duration", (event: any) => { - setDuration(event.payload); - }); - - // Get the initial duration from the player - setTimeout(() => { - const playerDuration = newPlayer.getMetaData().totalTime; - if (playerDuration) { - setDuration(playerDuration); - } - }, 100); - - // Handle page visibility changes to prevent tab-switching issues - let wasPlayingBeforeHidden = false; - - handleVisibilityChange = () => { - if (document.hidden) { - // Tab became hidden - pause if playing and remember state - if (newPlayer && setIsPlaying) { - const playerState = newPlayer.getMetaData(); - wasPlayingBeforeHidden = playerState?.isPlaying || false; - if (wasPlayingBeforeHidden) { - newPlayer.pause(); - setIsPlaying(false); - } - } - } else { - // Tab became visible - resume if it was playing before - if (newPlayer && wasPlayingBeforeHidden) { - // Re-sync duration in case it got corrupted - const playerDuration = newPlayer.getMetaData().totalTime; - if (playerDuration) { - setDuration(playerDuration); - } - - // Resume playback - newPlayer.play(); - setIsPlaying(true); - wasPlayingBeforeHidden = false; - } - } - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - } catch (error) { - console.error("Failed to initialize rrweb player:", error); - return; - } - - return () => { - // Proper cleanup - if (newPlayer) { - newPlayer.pause(); - } - if (playerContainerRef.current) { - playerContainerRef.current.innerHTML = ""; - } - if (handleVisibilityChange) { - document.removeEventListener("visibilitychange", handleVisibilityChange); - } - playerRef.current = null; - setPlayer(null); - }; + const events = data?.events; + const playerContainer = playerContainerRef.current; + if (!events || !playerContainer) return; + + playerContainer.innerHTML = ""; + let player: RrwebPlayerInstance; + + try { + player = new rrwebPlayer({ + target: playerContainer, + props: { + events: events as unknown as ConstructorParameters[0]["props"]["events"], + width: widthRef.current, + height: heightRef.current - CONTROLS_HEIGHT, + autoPlay: false, + showController: false, + }, + }) as unknown as RrwebPlayerInstance; + } catch (error) { + console.error("Failed to initialize rrweb player:", error); + return; } - }, [data, setPlayer, setCurrentTime, setIsPlaying, setDuration]); - // Update dimensions without recreating the player + playerRef.current = player; + const disconnectPlayer = connectPlayer(createPlayerAdapter(player), events); + const handleVisibilityChange = () => setPlayerVisibility(document.hidden); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + disconnectPlayer(); + playerContainer.innerHTML = ""; + playerRef.current = null; + }; + }, [connectPlayer, data?.events, setPlayerVisibility]); + useEffect(() => { - if (playerRef.current) { - playerRef.current.$set({ - width, - height: height - CONTROLS_HEIGHT, - }); - playerRef.current.triggerResize(); - } + if (!playerRef.current) return; + playerRef.current.$set({ + width, + height: height - CONTROLS_HEIGHT, + }); + playerRef.current.triggerResize(); }, [width, height]); return { playerContainerRef }; diff --git a/client/src/components/replay/player/utils/replayUtils.ts b/client/src/components/replay/player/utils/replayUtils.ts index 640031ed1..d89967550 100644 --- a/client/src/components/replay/player/utils/replayUtils.ts +++ b/client/src/components/replay/player/utils/replayUtils.ts @@ -5,38 +5,6 @@ export const formatTime = (ms: number): string => { return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; -export const calculateActivityPeriods = (events: any[], totalDuration: number): { start: number; end: number }[] => { - if (!events || events.length === 0) return []; - - // Filter for user interaction events (mouse moves, clicks, etc.) - const interactionEvents = events.filter(event => { - const eventType = parseInt(event.type.toString()); - // Type 3 = IncrementalSnapshot (includes mouse moves, clicks, etc.) - return eventType === 3; - }); - - const periods: { start: number; end: number }[] = []; - const inactivityThreshold = 5000; // 5 seconds of no interaction = inactive - const firstEventTime = events[0].timestamp; - - for (let i = 0; i < interactionEvents.length; i++) { - const currentEvent = interactionEvents[i]; - const nextEvent = interactionEvents[i + 1]; - - const currentTime = currentEvent.timestamp - firstEventTime; - const nextTime = nextEvent ? nextEvent.timestamp - firstEventTime : totalDuration; - - if (nextTime - currentTime <= inactivityThreshold) { - periods.push({ - start: currentTime, - end: nextTime, - }); - } - } - - return periods; -}; - export const PLAYBACK_SPEEDS = [ { value: "0.25", label: "0.25x" }, { value: "0.5", label: "0.5x" }, @@ -46,5 +14,4 @@ export const PLAYBACK_SPEEDS = [ ]; export const CONTROLS_HEIGHT = 101; -export const SKIP_SECONDS = 10000; // 10 seconds in milliseconds export const OVERLAY_TIMEOUT = 800; // milliseconds diff --git a/client/src/components/replay/replayStore.test.ts b/client/src/components/replay/replayStore.test.ts new file mode 100644 index 000000000..9ca1b08af --- /dev/null +++ b/client/src/components/replay/replayStore.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionReplayEvent } from "@/api/analytics/endpoints"; + +import { createReplayStore, type ReplayPlayerAdapter, type ReplayPlayerUpdate } from "./replayStore"; + +class FakePlayer implements ReplayPlayerAdapter { + duration = 0; + listeners = new Set<(update: ReplayPlayerUpdate) => void>(); + calls = { + play: 0, + pause: 0, + seek: [] as number[], + speed: [] as number[], + }; + + play() { + this.calls.play += 1; + } + + pause() { + this.calls.pause += 1; + } + + seek(time: number) { + this.calls.seek.push(time); + } + + setSpeed(speed: number) { + this.calls.speed.push(speed); + } + + getDuration() { + return this.duration; + } + + subscribe(listener: (update: ReplayPlayerUpdate) => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(update: ReplayPlayerUpdate) { + for (const listener of this.listeners) listener(update); + } +} + +const events: SessionReplayEvent[] = [ + { timestamp: 1_000, type: 4, data: {} }, + { timestamp: 2_000, type: 3, data: {} }, + { timestamp: 5_000, type: 3, data: {} }, + { timestamp: 12_000, type: 3, data: {} }, +]; + +describe("Replay Session Interface", () => { + it("opens, resets, and closes a session without exposing its player Adapter", () => { + const store = createReplayStore(); + const player = new FakePlayer(); + player.duration = 20_000; + + store.getState().openSession("session-a"); + const disconnect = store.getState().connectPlayer(player, events); + store.getState().play(); + store.getState().seekTo(8_000); + store.getState().changePlaybackSpeed(2); + + expect(store.getState()).not.toHaveProperty("player"); + expect(store.getState()).toMatchObject({ + sessionId: "session-a", + playerReady: true, + isPlaying: true, + currentTime: 8_000, + playbackSpeed: 2, + }); + + store.getState().resetPlayback(); + expect(store.getState()).toMatchObject({ + sessionId: "session-a", + playerReady: true, + isPlaying: false, + currentTime: 0, + duration: 20_000, + playbackSpeed: 1, + }); + expect(player.calls.seek.at(-1)).toBe(0); + expect(player.calls.speed.at(-1)).toBe(1); + + store.getState().openSession("session-b"); + expect(store.getState()).toMatchObject({ + sessionId: "session-b", + playerReady: false, + currentTime: 0, + duration: 0, + }); + expect(player.listeners.size).toBe(0); + + store.getState().closeSession("session-a"); + expect(store.getState().sessionId).toBe("session-b"); + store.getState().closeSession("session-b"); + expect(store.getState().sessionId).toBe(""); + + // A hook cleanup racing with a session change remains harmless. + disconnect(); + }); + + it("coordinates play, pause, seek, scrub, skip, and speed through the Adapter", () => { + const store = createReplayStore(); + const player = new FakePlayer(); + player.duration = 20_000; + store.getState().openSession("session-a"); + store.getState().connectPlayer(player, events); + + store.getState().play(); + expect(player.calls.play).toBe(1); + expect(store.getState().isPlaying).toBe(true); + + store.getState().pause(); + expect(player.calls.pause).toBe(1); + expect(store.getState().isPlaying).toBe(false); + + store.getState().seekTo(7_000); + store.getState().skipBackward(); + store.getState().skipForward(); + store.getState().seekTo(30_000); + expect(player.calls.seek.slice(-4)).toEqual([7_000, 0, 10_000, 20_000]); + + store.getState().scrubTo(25); + expect(player.calls.pause).toBe(2); + expect(player.calls.seek.at(-1)).toBe(5_000); + expect(store.getState().currentTime).toBe(5_000); + + store.getState().changePlaybackSpeed(4); + expect(player.calls.speed.at(-1)).toBe(4); + expect(store.getState().playbackSpeed).toBe(4); + }); + + it("synchronizes player events and derives activity periods at the module Interface", () => { + const store = createReplayStore(); + const player = new FakePlayer(); + store.getState().openSession("session-a"); + const disconnect = store.getState().connectPlayer(player, events); + + player.duration = 20_000; + player.emit({ type: "duration", value: 20_000 }); + player.emit({ type: "playback-state", value: "playing" }); + player.emit({ type: "current-time", value: 5_000 }); + + expect(store.getState()).toMatchObject({ + duration: 20_000, + isPlaying: true, + currentTime: 5_000, + activityPeriods: [{ start: 1_000, end: 4_000 }], + }); + + player.emit({ type: "current-time", value: 25_000 }); + expect(player.calls.pause).toBe(1); + expect(store.getState()).toMatchObject({ currentTime: 20_000, isPlaying: false }); + + disconnect(); + player.emit({ type: "current-time", value: 2_000 }); + expect(store.getState().currentTime).toBe(20_000); + expect(store.getState().playerReady).toBe(false); + }); + + it("restores the underlying player after a drawer player disconnects", () => { + const store = createReplayStore(); + const pagePlayer = new FakePlayer(); + const drawerPlayer = new FakePlayer(); + pagePlayer.duration = drawerPlayer.duration = 20_000; + store.getState().openSession("session-a"); + + store.getState().connectPlayer(pagePlayer, events); + store.getState().play(); + store.getState().seekTo(4_000); + const disconnectDrawer = store.getState().connectPlayer(drawerPlayer, events); + + expect(pagePlayer.calls.pause).toBe(1); + expect(drawerPlayer.calls.play).toBe(1); + expect(drawerPlayer.calls.seek.at(-1)).toBe(4_000); + + drawerPlayer.emit({ type: "current-time", value: 7_000 }); + disconnectDrawer(); + + expect(pagePlayer.calls.seek.at(-1)).toBe(7_000); + expect(pagePlayer.calls.play).toBe(2); + expect(store.getState().playerReady).toBe(true); + }); + + it("pauses while hidden and resumes once when visibility is restored", () => { + const store = createReplayStore(); + const player = new FakePlayer(); + player.duration = 20_000; + store.getState().openSession("session-a"); + store.getState().connectPlayer(player, events); + store.getState().play(); + + store.getState().setPlayerVisibility(true); + store.getState().setPlayerVisibility(true); + expect(player.calls.pause).toBe(1); + expect(store.getState().isPlaying).toBe(false); + + store.getState().setPlayerVisibility(false); + store.getState().setPlayerVisibility(false); + expect(player.calls.play).toBe(2); + expect(store.getState().isPlaying).toBe(true); + }); + + it("resumes the underlying player when a hidden drawer disconnects", () => { + const store = createReplayStore(); + const pagePlayer = new FakePlayer(); + const drawerPlayer = new FakePlayer(); + pagePlayer.duration = drawerPlayer.duration = 20_000; + store.getState().openSession("session-a"); + + store.getState().connectPlayer(pagePlayer, events); + store.getState().play(); + const disconnectDrawer = store.getState().connectPlayer(drawerPlayer, events); + store.getState().setPlayerVisibility(true); + + disconnectDrawer(); + expect(store.getState().isPlaying).toBe(false); + + store.getState().setPlayerVisibility(false); + expect(pagePlayer.calls.play).toBe(2); + expect(store.getState().isPlaying).toBe(true); + }); +}); diff --git a/client/src/components/replay/replayStore.ts b/client/src/components/replay/replayStore.ts index cbfdca349..ccb994d1e 100644 --- a/client/src/components/replay/replayStore.ts +++ b/client/src/components/replay/replayStore.ts @@ -1,76 +1,324 @@ import { create } from "zustand"; +import { createStore } from "zustand/vanilla"; -interface ActivityPeriod { +import type { SessionReplayEvent } from "@/api/analytics/endpoints"; + +export interface ActivityPeriod { start: number; end: number; } -export const useReplayStore = create<{ +export type ReplayPlayerUpdate = + | { type: "current-time"; value: number } + | { type: "duration"; value: number } + | { type: "playback-state"; value: "playing" | "paused" }; + +/** The narrow seam between replay-session behavior and rrweb-player. */ +export interface ReplayPlayerAdapter { + play: () => void; + pause: () => void; + seek: (time: number) => void; + setSpeed: (speed: number) => void; + getDuration: () => number; + subscribe: (listener: (update: ReplayPlayerUpdate) => void) => () => void; +} + +export interface ReplayStore { minDuration: number; setMinDuration: (minDuration: number) => void; - // Session selection sessionId: string; - setSessionId: (sessionId: string) => void; + playerReady: boolean; + isPlaying: boolean; + currentTime: number; + duration: number; + playbackSpeed: number; + activityPeriods: ActivityPeriod[]; - // Player state - player: any; - setPlayer: (player: any) => void; + openSession: (sessionId: string) => void; + closeSession: (sessionId?: string) => void; + resetPlayback: () => void; - // Playback state - isPlaying: boolean; - setIsPlaying: (isPlaying: boolean) => void; + connectPlayer: (player: ReplayPlayerAdapter, events: SessionReplayEvent[]) => () => void; + setPlayerVisibility: (hidden: boolean) => void; - currentTime: number; - setCurrentTime: (currentTime: number) => void; + play: () => void; + pause: () => void; + togglePlayback: () => void; + skipBackward: () => void; + skipForward: () => void; + seekTo: (time: number) => void; + scrubTo: (percentage: number) => void; + changePlaybackSpeed: (speed: number) => void; +} - duration: number; - setDuration: (duration: number) => void; +interface PlayerConnection { + player: ReplayPlayerAdapter; + events: SessionReplayEvent[]; + unsubscribe: () => void; +} - playbackSpeed: string; - setPlaybackSpeed: (speed: string) => void; +const DEFAULT_PLAYBACK_SPEED = 1; +const SKIP_DURATION = 10_000; - activityPeriods: ActivityPeriod[]; - setActivityPeriods: (periods: ActivityPeriod[]) => void; +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function calculateActivityPeriods(events: SessionReplayEvent[], totalDuration: number): ActivityPeriod[] { + if (events.length === 0) return []; + + const interactionEvents = events.filter(event => Number(event.type) === 3); + const periods: ActivityPeriod[] = []; + const firstEventTime = events[0].timestamp; + + for (let index = 0; index < interactionEvents.length; index++) { + const currentEvent = interactionEvents[index]; + const nextEvent = interactionEvents[index + 1]; + const start = currentEvent.timestamp - firstEventTime; + const end = nextEvent ? nextEvent.timestamp - firstEventTime : totalDuration; + + if (end >= start && end - start <= 5_000) { + periods.push({ start, end }); + } + } + + return periods; +} - // Reset all player state when session changes - resetPlayerState: () => void; -}>(set => ({ - minDuration: 30, - setMinDuration: minDuration => set({ minDuration }), +function initialPlaybackState() { + return { + playerReady: false, + isPlaying: false, + currentTime: 0, + duration: 0, + playbackSpeed: DEFAULT_PLAYBACK_SPEED, + activityPeriods: [] as ActivityPeriod[], + }; +} + +const createReplayState = ( + set: (partial: Partial | ((state: ReplayStore) => Partial)) => void, + get: () => ReplayStore +): ReplayStore => { + let connections: PlayerConnection[] = []; + let resumeWhenVisible = false; + + const activeConnection = () => connections.at(-1); + + const setDurationFrom = (connection: PlayerConnection, value: number) => { + if (activeConnection() !== connection) return; - // Session selection - sessionId: "", - setSessionId: sessionId => set({ sessionId }), + const duration = finiteNonNegative(value); + set(state => ({ + duration, + currentTime: duration > 0 ? Math.min(state.currentTime, duration) : state.currentTime, + activityPeriods: calculateActivityPeriods(connection.events, duration), + })); + }; - // Player state - player: null, - setPlayer: player => set({ player }), + const synchronizePlayer = (connection: PlayerConnection, update: ReplayPlayerUpdate) => { + if (activeConnection() !== connection) return; - // Playback state - isPlaying: false, - setIsPlaying: isPlaying => set({ isPlaying }), + if (update.type === "duration") { + setDurationFrom(connection, update.value); + return; + } - currentTime: 0, - setCurrentTime: currentTime => set({ currentTime }), + if (update.type === "playback-state") { + set({ isPlaying: update.value === "playing" }); + return; + } - duration: 0, - setDuration: duration => set({ duration }), + const currentTime = finiteNonNegative(update.value); + const { duration } = get(); + if (duration > 0 && currentTime > duration) { + connection.player.pause(); + set({ currentTime: duration, isPlaying: false }); + return; + } - playbackSpeed: "1", - setPlaybackSpeed: playbackSpeed => set({ playbackSpeed }), + set({ currentTime }); + }; - activityPeriods: [], - setActivityPeriods: activityPeriods => set({ activityPeriods }), + const activate = (connection: PlayerConnection) => { + const state = get(); + const duration = finiteNonNegative(connection.player.getDuration()) || state.duration; + const currentTime = duration > 0 ? Math.min(state.currentTime, duration) : state.currentTime; - // Reset all player state when session changes - resetPlayerState: () => set({ - player: null, - isPlaying: false, - currentTime: 0, - duration: 0, - playbackSpeed: "1", - activityPeriods: [], - }), -})); + playerReady: true, + duration, + currentTime, + activityPeriods: calculateActivityPeriods(connection.events, duration), + }); + + connection.player.setSpeed(state.playbackSpeed); + if (currentTime > 0) connection.player.seek(currentTime); + if (state.isPlaying) connection.player.play(); + }; + + const disconnectAll = () => { + const previousConnections = connections; + connections = []; + + for (const connection of previousConnections) { + connection.unsubscribe(); + connection.player.pause(); + } + }; + + const pauseActive = (rememberForVisibility = false) => { + const connection = activeConnection(); + if (!connection) return; + if (!rememberForVisibility) resumeWhenVisible = false; + connection.player.pause(); + set({ isPlaying: false }); + }; + + const seekActive = (time: number) => { + const connection = activeConnection(); + if (!connection) return; + + const { duration } = get(); + const upperBound = duration > 0 ? duration : Number.POSITIVE_INFINITY; + const currentTime = Math.min(finiteNonNegative(time), upperBound); + connection.player.seek(currentTime); + set({ currentTime }); + }; + + return { + minDuration: 30, + setMinDuration: minDuration => set({ minDuration: finiteNonNegative(minDuration) }), + + sessionId: "", + ...initialPlaybackState(), + + openSession: sessionId => { + if (!sessionId || sessionId === get().sessionId) return; + disconnectAll(); + resumeWhenVisible = false; + set({ sessionId, ...initialPlaybackState() }); + }, + + closeSession: sessionId => { + if (sessionId && sessionId !== get().sessionId) return; + disconnectAll(); + resumeWhenVisible = false; + set({ sessionId: "", ...initialPlaybackState() }); + }, + + resetPlayback: () => { + resumeWhenVisible = false; + const connection = activeConnection(); + connection?.player.pause(); + connection?.player.seek(0); + connection?.player.setSpeed(DEFAULT_PLAYBACK_SPEED); + + const duration = connection ? finiteNonNegative(connection.player.getDuration()) : 0; + set({ + ...initialPlaybackState(), + playerReady: !!connection, + duration, + activityPeriods: connection ? calculateActivityPeriods(connection.events, duration) : [], + }); + }, + + connectPlayer: (player, events) => { + const previousActive = activeConnection(); + const connection: PlayerConnection = { + player, + events, + unsubscribe: () => undefined, + }; + + connections.push(connection); + connection.unsubscribe = player.subscribe(update => synchronizePlayer(connection, update)); + + // A drawer can mount a second view of the same replay. The newest player + // becomes active while the underlying one remains available to restore. + previousActive?.player.pause(); + activate(connection); + + let connected = true; + return () => { + if (!connected) return; + connected = false; + + const wasActive = activeConnection() === connection; + connections = connections.filter(candidate => candidate !== connection); + connection.unsubscribe(); + connection.player.pause(); + + if (!wasActive) return; + const nextActive = activeConnection(); + if (nextActive) { + activate(nextActive); + } else { + resumeWhenVisible = false; + set({ playerReady: false, isPlaying: false }); + } + }; + }, + + setPlayerVisibility: hidden => { + const connection = activeConnection(); + if (!connection) return; + + if (hidden) { + if (resumeWhenVisible) return; + resumeWhenVisible = get().isPlaying; + if (resumeWhenVisible) pauseActive(true); + return; + } + + if (!resumeWhenVisible) return; + resumeWhenVisible = false; + setDurationFrom(connection, connection.player.getDuration()); + connection.player.play(); + set({ isPlaying: true }); + }, + + play: () => { + const connection = activeConnection(); + if (!connection) return; + resumeWhenVisible = false; + connection.player.play(); + set({ isPlaying: true }); + }, + + pause: () => pauseActive(), + + togglePlayback: () => { + if (get().isPlaying) { + pauseActive(); + } else { + get().play(); + } + }, + + skipBackward: () => seekActive(get().currentTime - SKIP_DURATION), + skipForward: () => seekActive(get().currentTime + SKIP_DURATION), + seekTo: seekActive, + + scrubTo: percentage => { + const { duration } = get(); + if (!activeConnection() || duration <= 0) return; + pauseActive(); + seekActive((Math.min(100, Math.max(0, percentage)) / 100) * duration); + }, + + changePlaybackSpeed: speed => { + const connection = activeConnection(); + if (!connection || !Number.isFinite(speed) || speed <= 0) return; + connection.player.setSpeed(speed); + set({ playbackSpeed: speed }); + }, + }; +}; + +export function createReplayStore() { + return createStore()(createReplayState); +} + +export const useReplayStore = create()(createReplayState); diff --git a/client/src/components/sidebar/NavigationSidebar.tsx b/client/src/components/sidebar/NavigationSidebar.tsx index 114c89a8f..ee4f91e38 100644 --- a/client/src/components/sidebar/NavigationSidebar.tsx +++ b/client/src/components/sidebar/NavigationSidebar.tsx @@ -2,8 +2,7 @@ import { AppWindow, Building2, Combine, CreditCard, UserCircle, Users } from "lucide-react"; import { useExtracted } from "next-intl"; import { usePathname } from "next/navigation"; -import { useUserOrganizations } from "../../api/admin/hooks/useOrganizations"; -import { authClient } from "../../lib/auth"; +import { useOrganizationAccess } from "../../hooks/useOrganizationAccess"; import { IS_CLOUD } from "../../lib/const"; import { OrganizationSelector } from "../OrganizationSelector"; import { Sidebar } from "./Sidebar"; @@ -11,14 +10,7 @@ import { Sidebar } from "./Sidebar"; export function NavigationSidebar() { const t = useExtracted(); const pathname = usePathname(); - const { data: activeOrganization } = authClient.useActiveOrganization(); - const { data: userOrganizations } = useUserOrganizations(); - - const currentMember = userOrganizations?.find( - (org) => org.id === activeOrganization?.id - ); - const isAdminOrOwner = - currentMember?.role === "admin" || currentMember?.role === "owner"; + const access = useOrganizationAccess(); return ( @@ -44,29 +36,29 @@ export function NavigationSidebar() { href="/settings/account" icon={} /> - {isAdminOrOwner && ( - <> - } - /> - } - /> - {IS_CLOUD && ( - } - /> - )} - + {access.decisions.manageOrganizationSettings.allowed && ( + } + /> + )} + {access.decisions.manageTeams.allowed && ( + } + /> + )} + {IS_CLOUD && access.decisions.viewSubscriptionSettings.allowed && ( + } + /> )} diff --git a/client/src/hooks/useOrganizationAccess.ts b/client/src/hooks/useOrganizationAccess.ts new file mode 100644 index 000000000..c68093353 --- /dev/null +++ b/client/src/hooks/useOrganizationAccess.ts @@ -0,0 +1,32 @@ +import { useUserOrganizations } from "@/api/admin/hooks/useOrganizations"; +import { authClient } from "@/lib/auth"; +import { resolveOrganizationAccess } from "@/lib/organizationAccess"; + +export function useOrganizationAccess(targetOrganizationId?: string | null) { + const activeOrganization = authClient.useActiveOrganization(); + const memberships = useUserOrganizations(); + const session = authClient.useSession(); + const usesActiveOrganization = targetOrganizationId === undefined; + const organizationId = usesActiveOrganization ? (activeOrganization.data?.id ?? null) : targetOrganizationId; + + const access = resolveOrganizationAccess({ + activeOrganizationError: usesActiveOrganization ? activeOrganization.error : null, + activeOrganizationPending: usesActiveOrganization && activeOrganization.isPending, + globalRole: session.data?.user.role ?? null, + memberships: memberships.data, + membershipsError: memberships.error, + membershipsPending: memberships.isPending, + organizationId, + sessionError: session.error, + sessionPending: session.isPending, + }); + + return { + ...access, + retry: async () => { + const retries: Promise[] = [memberships.refetch()]; + if (usesActiveOrganization) retries.push(activeOrganization.refetch()); + await Promise.all(retries); + }, + }; +} diff --git a/client/src/lib/defaultTimeRange.test.ts b/client/src/lib/defaultTimeRange.test.ts index 03ea16681..29096e07d 100644 --- a/client/src/lib/defaultTimeRange.test.ts +++ b/client/src/lib/defaultTimeRange.test.ts @@ -1,5 +1,39 @@ import { describe, expect, it } from "vitest"; -import { capDashboardDefaultRange } from "./defaultTimeRange"; +import { + capDashboardDefaultRange, + DASHBOARD_DEFAULT_TIME_RANGES, + DASHBOARD_TIME_PRESET_GROUPS, + getDashboardTimeForRange, +} from "./defaultTimeRange"; + +const EXPECTED_PRESET_GROUPS = [ + ["last-30-minutes", "last-1-hour", "last-6-hours", "last-24-hours"], + ["today", "yesterday", "last-3-days", "last-7-days", "last-14-days", "last-30-days", "last-60-days"], + ["this-week", "last-week", "this-month", "last-month", "this-year", "all-time"], +] as const; + +describe("dashboard time presets", () => { + it("makes every well-known range selectable and constructible", () => { + expect(DASHBOARD_TIME_PRESET_GROUPS.map(group => group.ranges)).toEqual(EXPECTED_PRESET_GROUPS); + expect(DASHBOARD_DEFAULT_TIME_RANGES).toEqual(EXPECTED_PRESET_GROUPS.flat()); + + for (const range of EXPECTED_PRESET_GROUPS.flat()) { + expect(getDashboardTimeForRange(range, "America/New_York").wellKnown).toBe(range); + } + }); + + it("includes last week and last month in the calendar-period menu group", () => { + const calendarPeriods = DASHBOARD_TIME_PRESET_GROUPS.find(group => group.key === "calendar-periods"); + + expect(calendarPeriods?.ranges).toContain("last-week"); + expect(calendarPeriods?.ranges).toContain("last-month"); + expect(getDashboardTimeForRange("last-week", "UTC")).toMatchObject({ mode: "week", wellKnown: "last-week" }); + expect(getDashboardTimeForRange("last-month", "UTC")).toMatchObject({ + mode: "month", + wellKnown: "last-month", + }); + }); +}); describe("capDashboardDefaultRange", () => { it("caps an all-time default on the users page", () => { diff --git a/client/src/lib/defaultTimeRange.ts b/client/src/lib/defaultTimeRange.ts index b7368b1a9..cafae57d2 100644 --- a/client/src/lib/defaultTimeRange.ts +++ b/client/src/lib/defaultTimeRange.ts @@ -5,56 +5,45 @@ export type DashboardDefaultTimeRange = NonNullable; export const DASHBOARD_DEFAULT_TIME_RANGE_STORAGE_KEY = "rybbit-default-time-range"; -export const DASHBOARD_DEFAULT_TIME_RANGES = [ - "today", - "yesterday", - "last-3-days", - "last-7-days", - "last-14-days", - "last-30-days", - "last-60-days", - "this-week", - "last-week", - "this-month", - "last-month", - "this-year", - "last-30-minutes", - "last-1-hour", - "last-6-hours", - "last-24-hours", - "all-time", -] as const satisfies readonly DashboardDefaultTimeRange[]; +export const DASHBOARD_TIME_PRESET_GROUPS = [ + { + key: "past-minutes", + pastMinutesOnly: true, + ranges: ["last-30-minutes", "last-1-hour", "last-6-hours", "last-24-hours"], + }, + { + key: "recent-days", + pastMinutesOnly: false, + ranges: ["today", "yesterday", "last-3-days", "last-7-days", "last-14-days", "last-30-days", "last-60-days"], + }, + { + key: "calendar-periods", + pastMinutesOnly: false, + ranges: ["this-week", "last-week", "this-month", "last-month", "this-year", "all-time"], + }, +] as const satisfies readonly { + key: string; + pastMinutesOnly: boolean; + ranges: readonly DashboardDefaultTimeRange[]; +}[]; + +export const DASHBOARD_DEFAULT_TIME_RANGES: readonly DashboardDefaultTimeRange[] = DASHBOARD_TIME_PRESET_GROUPS.flatMap( + group => group.ranges +); const DEFAULT_DASHBOARD_TIME_RANGE: DashboardDefaultTimeRange = "today"; const DEFAULT_TIME_RANGE_ALIASES: Record = { - today: "today", - yesterday: "yesterday", - "last-3-days": "last-3-days", "last-3d": "last-3-days", - "last-7-days": "last-7-days", "last-7d": "last-7-days", - "last-14-days": "last-14-days", "last-14d": "last-14-days", - "last-30-days": "last-30-days", "last-30d": "last-30-days", - "last-60-days": "last-60-days", "last-60d": "last-60-days", - "this-week": "this-week", - "last-week": "last-week", - "this-month": "this-month", - "last-month": "last-month", - "this-year": "this-year", - "last-30-minutes": "last-30-minutes", "last-30m": "last-30-minutes", - "last-1-hour": "last-1-hour", "last-1h": "last-1-hour", "last-hour": "last-1-hour", - "last-6-hours": "last-6-hours", "last-6h": "last-6-hours", - "last-24-hours": "last-24-hours", "last-24h": "last-24-hours", - "all-time": "all-time", all: "all-time", }; @@ -63,7 +52,8 @@ export function normalizeDashboardDefaultTimeRange( fallback: DashboardDefaultTimeRange = DEFAULT_DASHBOARD_TIME_RANGE ): DashboardDefaultTimeRange { const key = value?.trim().toLowerCase().replace(/_/g, "-"); - return (key && DEFAULT_TIME_RANGE_ALIASES[key]) || fallback; + const canonicalRange = DASHBOARD_DEFAULT_TIME_RANGES.find(range => range === key); + return canonicalRange || (key && DEFAULT_TIME_RANGE_ALIASES[key]) || fallback; } export function getStoredDashboardDefaultTimeRange(): DashboardDefaultTimeRange { diff --git a/client/src/lib/organizationAccess.test.ts b/client/src/lib/organizationAccess.test.ts new file mode 100644 index 000000000..ffc6636ae --- /dev/null +++ b/client/src/lib/organizationAccess.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOrganizationAccess } from "./organizationAccess"; + +const ORGANIZATION_ID = "org-1"; + +function accessForRole(role: string) { + return resolveOrganizationAccess({ + globalRole: "user", + memberships: [{ id: ORGANIZATION_ID, role }], + organizationId: ORGANIZATION_ID, + }); +} + +describe("resolveOrganizationAccess", () => { + it("keeps every operation closed while the active Organization is pending", () => { + const access = resolveOrganizationAccess({ + activeOrganizationPending: true, + memberships: [{ id: ORGANIZATION_ID, role: "owner" }], + organizationId: ORGANIZATION_ID, + }); + + expect(access.facts.activeOrganization.status).toBe("pending"); + expect(access.decisions.manageOrganizationSettings).toEqual({ + allowed: false, + reason: "active-organization-pending", + }); + expect(access.decisions.manageTeams.allowed).toBe(false); + expect(access.decisions.viewSubscriptionSettings.allowed).toBe(false); + }); + + it("keeps protected operations closed while memberships are pending", () => { + const access = resolveOrganizationAccess({ + membershipsPending: true, + organizationId: ORGANIZATION_ID, + }); + + expect(access.facts.membership.status).toBe("pending"); + expect(access.decisions.manageOrganizationSettings).toEqual({ + allowed: false, + reason: "membership-pending", + }); + }); + + it("distinguishes having no active Organization from missing membership", () => { + const access = resolveOrganizationAccess({ + memberships: [], + organizationId: null, + }); + + expect(access.facts.activeOrganization.status).toBe("missing"); + expect(access.decisions.manageOrganizationSettings).toEqual({ + allowed: false, + reason: "no-active-organization", + }); + }); + + it("distinguishes a missing membership from pending membership", () => { + const access = resolveOrganizationAccess({ + memberships: [], + organizationId: ORGANIZATION_ID, + }); + + expect(access.facts.membership).toMatchObject({ + organizationRole: null, + status: "missing", + }); + expect(access.decisions.manageOrganizationSettings).toEqual({ + allowed: false, + reason: "membership-missing", + }); + }); + + it("preserves query errors and denies access when no membership data is available", () => { + const error = new Error("memberships unavailable"); + const access = resolveOrganizationAccess({ + membershipsError: error, + organizationId: ORGANIZATION_ID, + }); + + expect(access.facts.membership).toMatchObject({ error, status: "error" }); + expect(access.decisions.manageTeams).toEqual({ + allowed: false, + reason: "membership-error", + }); + }); + + it("does not grant access from cached membership data when its refresh failed", () => { + const error = new Error("membership refresh failed"); + const access = resolveOrganizationAccess({ + memberships: [{ id: ORGANIZATION_ID, role: "owner" }], + membershipsError: error, + organizationId: ORGANIZATION_ID, + }); + + expect(access.facts.membership).toMatchObject({ + error, + organizationRole: "owner", + status: "error", + }); + expect(access.decisions.manageOrganizationSettings).toEqual({ + allowed: false, + reason: "membership-error", + }); + expect(access.decisions.manageSiteConfiguration).toEqual({ + allowed: false, + reason: "membership-error", + }); + }); + + it("denies a member each protected settings operation", () => { + const access = accessForRole("member"); + + expect(access.facts.membership.organizationRole).toBe("member"); + expect(access.decisions.manageOrganizationSettings.allowed).toBe(false); + expect(access.decisions.manageTeams.allowed).toBe(false); + expect(access.decisions.viewSubscriptionSettings.allowed).toBe(false); + expect(access.decisions.manageSubscription.allowed).toBe(false); + }); + + it("allows an admin to manage Organization and Team settings but not the subscription", () => { + const access = accessForRole("admin"); + + expect(access.facts.membership.organizationRole).toBe("admin"); + expect(access.decisions.manageOrganizationSettings.allowed).toBe(true); + expect(access.decisions.manageTeams.allowed).toBe(true); + expect(access.decisions.viewSubscriptionSettings.allowed).toBe(true); + expect(access.decisions.manageSubscription).toEqual({ + allowed: false, + reason: "insufficient-organization-role", + }); + }); + + it("allows an owner every Organization settings operation", () => { + const access = accessForRole("owner"); + + expect(access.facts.membership.organizationRole).toBe("owner"); + expect(access.decisions.manageOrganizationSettings.allowed).toBe(true); + expect(access.decisions.manageTeams.allowed).toBe(true); + expect(access.decisions.viewSubscriptionSettings.allowed).toBe(true); + expect(access.decisions.manageSubscription.allowed).toBe(true); + }); + + it("keeps the global-admin decision separate from Organization roles", () => { + const access = resolveOrganizationAccess({ + globalRole: "admin", + memberships: [], + organizationId: ORGANIZATION_ID, + }); + + expect(access.facts.actor.globalRole).toBe("admin"); + expect(access.facts.membership.organizationRole).toBeNull(); + expect(access.decisions.manageOrganizationSettings.allowed).toBe(false); + expect(access.decisions.manageSiteConfiguration.allowed).toBe(true); + }); + + it("lets a global admin recover Site Configuration without an Organization", () => { + const access = resolveOrganizationAccess({ + globalRole: "admin", + memberships: [], + organizationId: null, + }); + + expect(access.decisions.manageSiteConfiguration.allowed).toBe(true); + }); + + it("keeps Site Configuration closed until the actor role is resolved", () => { + const access = resolveOrganizationAccess({ + memberships: [{ id: ORGANIZATION_ID, role: "member" }], + organizationId: ORGANIZATION_ID, + sessionPending: true, + }); + + expect(access.decisions.manageSiteConfiguration).toEqual({ + allowed: false, + reason: "session-pending", + }); + }); +}); diff --git a/client/src/lib/organizationAccess.ts b/client/src/lib/organizationAccess.ts new file mode 100644 index 000000000..8f644ed83 --- /dev/null +++ b/client/src/lib/organizationAccess.ts @@ -0,0 +1,209 @@ +export type OrganizationAccessDenialReason = + | "active-organization-pending" + | "active-organization-error" + | "no-active-organization" + | "membership-pending" + | "membership-error" + | "membership-missing" + | "insufficient-organization-role" + | "session-pending" + | "session-error"; + +export type OrganizationAccessDecision = { allowed: true } | { allowed: false; reason: OrganizationAccessDenialReason }; + +export type OrganizationAccessStatus = "error" | "missing" | "pending" | "ready"; + +export interface OrganizationMembershipReference { + id: string; + role: string; +} + +export interface OrganizationAccessFacts { + activeOrganization: { + error: unknown; + id: string | null; + status: OrganizationAccessStatus; + }; + actor: { + error: unknown; + globalRole: string | null; + status: OrganizationAccessStatus; + }; + membership: { + error: unknown; + organizationRole: string | null; + status: OrganizationAccessStatus; + }; +} + +export interface OrganizationAccess { + decisions: { + manageOrganizationSettings: OrganizationAccessDecision; + manageSiteConfiguration: OrganizationAccessDecision; + manageSubscription: OrganizationAccessDecision; + manageTeams: OrganizationAccessDecision; + viewSubscriptionSettings: OrganizationAccessDecision; + }; + facts: OrganizationAccessFacts; +} + +export interface ResolveOrganizationAccessInput { + activeOrganizationError?: unknown; + activeOrganizationPending?: boolean; + globalRole?: string | null; + memberships?: readonly OrganizationMembershipReference[]; + membershipsError?: unknown; + membershipsPending?: boolean; + organizationId: string | null; + sessionError?: unknown; + sessionPending?: boolean; +} + +const ALLOWED: OrganizationAccessDecision = { allowed: true }; + +function denied(reason: OrganizationAccessDenialReason): OrganizationAccessDecision { + return { allowed: false, reason }; +} + +function decideForOrganizationManager(facts: OrganizationAccessFacts): OrganizationAccessDecision { + if (facts.activeOrganization.status === "pending") { + return denied("active-organization-pending"); + } + if (facts.activeOrganization.status === "error") { + return denied("active-organization-error"); + } + if (facts.activeOrganization.status === "missing") { + return denied("no-active-organization"); + } + if (facts.membership.status === "pending") { + return denied("membership-pending"); + } + if (facts.membership.status === "error") { + return denied("membership-error"); + } + if (facts.membership.status === "missing") { + return denied("membership-missing"); + } + + return facts.membership.organizationRole === "admin" || facts.membership.organizationRole === "owner" + ? ALLOWED + : denied("insufficient-organization-role"); +} + +function decideForSubscriptionManagement(facts: OrganizationAccessFacts): OrganizationAccessDecision { + const settingsDecision = decideForOrganizationManager(facts); + if (!settingsDecision.allowed) { + return settingsDecision; + } + + return facts.membership.organizationRole === "owner" ? ALLOWED : denied("insufficient-organization-role"); +} + +function decideForSiteConfiguration(facts: OrganizationAccessFacts): OrganizationAccessDecision { + const isGlobalAdmin = facts.actor.status === "ready" && facts.actor.globalRole === "admin"; + if (isGlobalAdmin) { + return ALLOWED; + } + + if (facts.activeOrganization.status === "pending") { + return denied("active-organization-pending"); + } + if (facts.activeOrganization.status === "error") { + return denied("active-organization-error"); + } + if (facts.activeOrganization.status === "missing") { + return denied("no-active-organization"); + } + + const hasOrganizationRole = + facts.membership.status === "ready" && + (facts.membership.organizationRole === "admin" || facts.membership.organizationRole === "owner"); + if (hasOrganizationRole) { + return ALLOWED; + } + if (facts.membership.status === "pending") { + return denied("membership-pending"); + } + if (facts.actor.status === "pending") { + return denied("session-pending"); + } + if (facts.membership.status === "error") { + return denied("membership-error"); + } + if (facts.actor.status === "error") { + return denied("session-error"); + } + if (facts.membership.status === "missing") { + return denied("membership-missing"); + } + + return denied("insufficient-organization-role"); +} + +export function resolveOrganizationAccess({ + activeOrganizationError = null, + activeOrganizationPending = false, + globalRole = null, + memberships, + membershipsError = null, + membershipsPending = false, + organizationId, + sessionError = null, + sessionPending = false, +}: ResolveOrganizationAccessInput): OrganizationAccess { + const membership = memberships?.find(candidate => candidate.id === organizationId); + + const activeOrganizationStatus: OrganizationAccessStatus = activeOrganizationPending + ? "pending" + : activeOrganizationError + ? "error" + : organizationId + ? "ready" + : "missing"; + const membershipStatus: OrganizationAccessStatus = membershipsPending + ? "pending" + : membershipsError + ? "error" + : membership + ? "ready" + : "missing"; + const actorStatus: OrganizationAccessStatus = sessionPending + ? "pending" + : sessionError + ? "error" + : globalRole + ? "ready" + : "missing"; + + const facts: OrganizationAccessFacts = { + activeOrganization: { + error: activeOrganizationError, + id: organizationId, + status: activeOrganizationStatus, + }, + actor: { + error: sessionError, + globalRole, + status: actorStatus, + }, + membership: { + error: membershipsError, + organizationRole: membership?.role ?? null, + status: membershipStatus, + }, + }; + const managerDecision = decideForOrganizationManager(facts); + + return { + decisions: { + // These operations intentionally share today's admin/owner policy while + // retaining intent-specific names so they can diverge explicitly later. + manageOrganizationSettings: managerDecision, + manageSiteConfiguration: decideForSiteConfiguration(facts), + manageSubscription: decideForSubscriptionManagement(facts), + manageTeams: managerDecision, + viewSubscriptionSettings: managerDecision, + }, + facts, + }; +} diff --git a/client/src/lib/store.ts b/client/src/lib/store.ts index 5f4db2ab6..03e4bcb01 100644 --- a/client/src/lib/store.ts +++ b/client/src/lib/store.ts @@ -57,7 +57,7 @@ type PersistedStore = Pick; const getUrlParams = () => (typeof window !== "undefined" ? new URLSearchParams(globalThis.location.search) : null); -const getDefaultTime = (): Time => getStoredDashboardDefaultTime(getSystemTimezone()); +const getDefaultTime = (): Time => getStoredDashboardDefaultTime(getTimezone()); const getDefaultTimeState = () => getTimeState(getDefaultTime()); diff --git a/client/src/lib/subscription/planUtils.test.tsx b/client/src/lib/subscription/planUtils.test.tsx index 1db27f9a2..b88a57072 100644 --- a/client/src/lib/subscription/planUtils.test.tsx +++ b/client/src/lib/subscription/planUtils.test.tsx @@ -1,6 +1,13 @@ import { afterAll, describe, expect, it, vi } from "vitest"; import { getStripePrices, STRIPE_TIERS } from "../stripe"; -import { EVENT_TIERS, findPriceForTier, formatDate, formatEventTier, planIncludesReplay } from "./planUtils"; +import { + EVENT_TIERS, + findPriceForTier, + formatDate, + formatEventTier, + planIncludesReplay, + planIncludesStandardFeatures, +} from "./planUtils"; // formatDate parses a bare "yyyy-MM-dd" as UTC midnight and then renders it in // the machine's timezone, so the zone has to be pinned for these assertions to @@ -159,3 +166,19 @@ describe("planIncludesReplay", () => { expect(planIncludesReplay({ planName: "pro100k", isTrial: true })).toBe(true); }); }); + +describe("planIncludesStandardFeatures", () => { + it("includes every paid plan family that grants Standard tracking features", () => { + for (const planName of ["standard100k", "standard1m-annual", "pro100k", "custom", "appsumo-1"]) { + expect(planIncludesStandardFeatures({ planName }), planName).toBe(true); + } + }); + + it("excludes plans without Standard tracking features", () => { + for (const planName of ["free", "basic100k", "hobby"]) { + expect(planIncludesStandardFeatures({ planName }), planName).toBe(false); + } + expect(planIncludesStandardFeatures(null)).toBe(false); + expect(planIncludesStandardFeatures(undefined)).toBe(false); + }); +}); diff --git a/client/src/lib/subscription/planUtils.tsx b/client/src/lib/subscription/planUtils.tsx index 063cfc6b7..6bf1409ad 100644 --- a/client/src/lib/subscription/planUtils.tsx +++ b/client/src/lib/subscription/planUtils.tsx @@ -54,6 +54,14 @@ export function planIncludesReplay( return subscription.planName.includes("pro") && !isLargeTrial; } +// Whether a plan includes the Standard tracking features. Kept separate from +// replay because the product tiers grant these capabilities differently. +export function planIncludesStandardFeatures(subscription: { planName: string } | null | undefined): boolean { + if (!subscription) return false; + + return ["custom", "standard", "pro", "appsumo"].some(planFamily => subscription.planName.includes(planFamily)); +} + // Format event tier for display export function formatEventTier(tier: number | string): string { if (typeof tier === "string") {