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.")}
{
- await updateSiteConfig(siteId, { trackErrors: true });
+ await updateSiteConfiguration({ siteId, config: { trackErrors: true } });
toast.success(t("Error tracking enabled"));
- refetch();
}}
>
{t("Enable")}
diff --git a/client/src/app/[site]/experiments/components/CreateExperimentWizard.tsx b/client/src/app/[site]/experiments/components/CreateExperimentWizard.tsx
index 66aee2e3f..62768013b 100644
--- a/client/src/app/[site]/experiments/components/CreateExperimentWizard.tsx
+++ b/client/src/app/[site]/experiments/components/CreateExperimentWizard.tsx
@@ -539,100 +539,14 @@ export function CreateExperimentWizard({
const implementationState = savedImplementationState || (isEditing ? buildImplementationState("updated") : null);
- const submitExperiment = async () => {
+ const authorExperiment = async (mode: ImplementationState["mode"]) => {
const validationError = validateExperimentConfiguration();
if (validationError) {
toast.error(validationError);
return;
}
- try {
- let featureFlagId = Number(form.existingFlagId);
- let flagKey = selectedFlag?.key || form.flagKey.trim();
- let variantKeys = selectedFlag ? getVariantKeys(selectedFlag) : form.variants.map(variant => variant.key.trim());
-
- if (form.assignmentMode === "new") {
- const variants: FeatureFlagVariant[] = form.variants.map(variant => ({
- key: variant.key.trim(),
- name: variant.name.trim() || undefined,
- rolloutPercentage: Number(variant.rolloutPercentage),
- }));
-
- const createdFlag = await createFeatureFlagMutation.mutateAsync({
- key: form.flagKey.trim(),
- description: form.flagDescription.trim() || `Assignment flag for ${form.name.trim()}`,
- enabled: true,
- runtime: "client",
- flagType: "multivariate",
- payload: null,
- variants: [],
- rolloutPercentage: 100,
- rules: [],
- conditionSets: [
- {
- name: "Default",
- rules: [],
- variants,
- },
- ],
- });
-
- featureFlagId = createdFlag.data.flagId;
- flagKey = createdFlag.data.key;
- variantKeys = getVariantKeys(createdFlag.data);
- }
-
- let primaryGoalId: number | null = form.goalMode === "existing" ? Number(form.existingGoalId) : null;
- let goalType: AnyGoalType | undefined = selectedGoal?.goalType;
- let goalLabel = form.goalMode === "none" ? undefined : (goalDisplayPattern(selectedGoal) ?? form.goalName.trim());
-
- if (form.goalMode === "new") {
- const createdGoal = await createGoalMutation.mutateAsync({
- siteId: Number(site),
- name: form.goalName.trim() || `${form.name.trim()} conversion`,
- goalType: form.goalType,
- config:
- form.goalType === "path" ? { pathPattern: form.pathPattern.trim() } : { eventName: form.eventName.trim() },
- });
-
- primaryGoalId = createdGoal.goalId;
- goalType = form.goalType;
- goalLabel = form.goalType === "path" ? form.pathPattern.trim() : form.eventName.trim();
- }
-
- const createdExperiment = await createExperimentMutation.mutateAsync({
- name: form.name.trim(),
- description: form.description.trim() || null,
- hypothesis: form.hypothesis.trim() || null,
- featureFlagId,
- primaryGoalId,
- status: "draft",
- });
-
- setSavedImplementationState({
- mode: "created",
- experiment: createdExperiment.data,
- flagKey,
- variants: variantKeys,
- goalMode: form.goalMode,
- goalType,
- goalLabel,
- });
- setStep("implementation");
- toast.success(t("Experiment created"));
- } catch (error) {
- toast.error(error instanceof Error ? error.message : t("Failed to save experiment"));
- }
- };
-
- const saveExperiment = async () => {
- const validationError = validateExperimentConfiguration();
- if (validationError) {
- toast.error(validationError);
- return;
- }
-
- if (!experiment) return;
+ if (mode === "updated" && !experiment) return;
try {
let featureFlagId = Number(form.existingFlagId);
@@ -688,20 +602,35 @@ export function CreateExperimentWizard({
goalLabel = form.goalType === "path" ? form.pathPattern.trim() : form.eventName.trim();
}
- const updatedExperiment = await updateExperimentMutation.mutateAsync({
- experimentId: experiment.experimentId,
- payload: {
+ let savedExperiment: Experiment;
+
+ if (mode === "created") {
+ const createdExperiment = await createExperimentMutation.mutateAsync({
name: form.name.trim(),
description: form.description.trim() || null,
hypothesis: form.hypothesis.trim() || null,
featureFlagId,
primaryGoalId,
- },
- });
+ status: "draft",
+ });
+ savedExperiment = createdExperiment.data;
+ } else {
+ const updatedExperiment = await updateExperimentMutation.mutateAsync({
+ experimentId: experiment!.experimentId,
+ payload: {
+ name: form.name.trim(),
+ description: form.description.trim() || null,
+ hypothesis: form.hypothesis.trim() || null,
+ featureFlagId,
+ primaryGoalId,
+ },
+ });
+ savedExperiment = updatedExperiment.data;
+ }
setSavedImplementationState({
- mode: "updated",
- experiment: updatedExperiment.data,
+ mode,
+ experiment: savedExperiment,
flagKey,
variants: variantKeys,
goalMode: form.goalMode,
@@ -709,12 +638,15 @@ export function CreateExperimentWizard({
goalLabel,
});
setStep("implementation");
- toast.success(t("Experiment updated"));
+ toast.success(mode === "created" ? t("Experiment created") : t("Experiment updated"));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("Failed to save experiment"));
}
};
+ const submitExperiment = () => authorExperiment("created");
+ const saveExperiment = () => authorExperiment("updated");
+
const renderStep = () => {
if (step === "basics") {
return (
@@ -1086,7 +1018,9 @@ window.rybbit.onReady((rybbit) => {
) : implementationState.goalType && implementationState.goalType !== "event" ? (
- {t("No conversion event code is needed for this goal. Rybbit tracks it automatically based on user behavior.")}
+ {t(
+ "No conversion event code is needed for this goal. Rybbit tracks it automatically based on user behavior."
+ )}
) : (
diff --git a/client/src/app/[site]/performance/components/EnableWebVitals.tsx b/client/src/app/[site]/performance/components/EnableWebVitals.tsx
index 6c2a1bba1..9f148a69a 100644
--- a/client/src/app/[site]/performance/components/EnableWebVitals.tsx
+++ b/client/src/app/[site]/performance/components/EnableWebVitals.tsx
@@ -4,7 +4,7 @@ import { useExtracted } from "next-intl";
import { Gauge } 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 EnableWebVitals() {
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?.webVitals) return null;
@@ -27,15 +28,15 @@ export function EnableWebVitals() {
- {t("Web Vitals collection provides Core Web Vitals metrics like LCP, CLS, and INP.")} {t("Note:")} {t("Enabling Web Vitals will increase your event usage.")}
+ {t("Web Vitals collection provides Core Web Vitals metrics like LCP, CLS, and INP.")} {t("Note:")} {" "}
+ {t("Enabling Web Vitals will increase your event usage.")}
{
- await updateSiteConfig(siteId, { webVitals: true });
+ await updateSiteConfiguration({ siteId, config: { webVitals: true } });
toast.success(t("Web Vitals collection enabled"));
- refetch();
}}
>
{t("Enable")}
diff --git a/client/src/app/[site]/replay/components/EnableSessionReplay.tsx b/client/src/app/[site]/replay/components/EnableSessionReplay.tsx
index cbe77e226..b4a49d6d0 100644
--- a/client/src/app/[site]/replay/components/EnableSessionReplay.tsx
+++ b/client/src/app/[site]/replay/components/EnableSessionReplay.tsx
@@ -4,7 +4,7 @@ import { Video } from "lucide-react";
import { useExtracted } from "next-intl";
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";
@@ -16,7 +16,8 @@ export function EnableSessionReplay() {
const t = useExtracted();
const params = useParams();
const siteId = Number(params.site);
- const { data: siteMetadata, isLoading, refetch } = useGetSite(siteId);
+ const { data: siteMetadata, isLoading } = useGetSite(siteId);
+ const { mutateAsync: updateSiteConfiguration } = useUpdateSiteConfiguration();
const { data: subscription } = useStripeSubscription();
const canEnableReplay = !IS_CLOUD || planIncludesReplay(subscription);
@@ -41,9 +42,8 @@ export function EnableSessionReplay() {
size="sm"
variant="success"
onClick={async () => {
- await updateSiteConfig(siteId, { sessionReplay: true });
+ await updateSiteConfiguration({ siteId, config: { sessionReplay: true } });
toast.success(t("Session replay enabled"));
- refetch();
}}
>
{t("Enable")}
diff --git a/client/src/app/[site]/replay/components/ReplayCard.tsx b/client/src/app/[site]/replay/components/ReplayCard.tsx
index 56eaf740c..f38a327bc 100644
--- a/client/src/app/[site]/replay/components/ReplayCard.tsx
+++ b/client/src/app/[site]/replay/components/ReplayCard.tsx
@@ -65,11 +65,11 @@ function cleanUrl(url: string) {
export function ReplayCard({ replay, onSelect }: { replay: SessionReplayListItem; onSelect?: () => void }) {
const t = useExtracted();
const { formatRelative } = useDateTimeFormat();
- const { sessionId, setSessionId, resetPlayerState } = useReplayStore(
+ const { sessionId, openSession, closeSession } = useReplayStore(
useShallow(s => ({
sessionId: s.sessionId,
- setSessionId: s.setSessionId,
- resetPlayerState: s.resetPlayerState,
+ openSession: s.openSession,
+ closeSession: s.closeSession,
}))
);
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -81,9 +81,7 @@ export function ReplayCard({ replay, onSelect }: { replay: SessionReplayListItem
const handleDelete = async () => {
try {
await deleteSessionReplay.mutateAsync({ sessionId: replay.session_id });
- if (sessionId === replay.session_id) {
- resetPlayerState();
- }
+ closeSession(replay.session_id);
setIsDialogOpen(false);
} catch (error) {
console.error("Failed to delete session replay:", error);
@@ -102,13 +100,13 @@ export function ReplayCard({ replay, onSelect }: { replay: SessionReplayListItem
isSelected && "bg-neutral-100 dark:bg-neutral-800/70"
)}
onClick={() => {
- setSessionId(replay.session_id);
+ openSession(replay.session_id);
onSelect?.();
}}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
- setSessionId(replay.session_id);
+ openSession(replay.session_id);
onSelect?.();
}
}}
@@ -190,7 +188,10 @@ export function ReplayCard({ replay, onSelect }: { replay: SessionReplayListItem
screen_height={replay.screen_height}
/>
-
+
{formatter(replay.event_count)}
diff --git a/client/src/app/[site]/replay/components/ReplayList.tsx b/client/src/app/[site]/replay/components/ReplayList.tsx
index 140d372dc..c0676c3aa 100644
--- a/client/src/app/[site]/replay/components/ReplayList.tsx
+++ b/client/src/app/[site]/replay/components/ReplayList.tsx
@@ -14,10 +14,10 @@ import { cn, formatter } from "../../../../lib/utils";
export function ReplayList({ onSelect }: { onSelect?: () => void }) {
const t = useExtracted();
- const { sessionId, setSessionId, minDuration, setMinDuration } = useReplayStore(
+ const { sessionId, openSession, minDuration, setMinDuration } = useReplayStore(
useShallow(s => ({
sessionId: s.sessionId,
- setSessionId: s.setSessionId,
+ openSession: s.openSession,
minDuration: s.minDuration,
setMinDuration: s.setMinDuration,
}))
@@ -42,9 +42,9 @@ export function ReplayList({ onSelect }: { onSelect?: () => void }) {
useEffect(() => {
if (flattenedData.length > 0 && !sessionId) {
- setSessionId(flattenedData[0].session_id);
+ openSession(flattenedData[0].session_id);
}
- }, [flattenedData]);
+ }, [flattenedData, openSession, sessionId]);
useEffect(() => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) {
@@ -95,7 +95,6 @@ export function ReplayList({ onSelect }: { onSelect?: () => void }) {
{/* [&>div]:!block forces Radix's display:table viewport wrapper to block so card truncate is bounded */}
-
{isLoading ? (
Array.from({ length: 20 }).map((_, index) => )
) : flattenedData.length === 0 ? (
diff --git a/client/src/app/components/AddSite.tsx b/client/src/app/components/AddSite.tsx
index bb85ae85b..894d7cec5 100644
--- a/client/src/app/components/AddSite.tsx
+++ b/client/src/app/components/AddSite.tsx
@@ -26,7 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../../components/ui/too
import { authClient } from "../../lib/auth";
import { IS_CLOUD } from "../../lib/const";
import { resetStore, useStore } from "../../lib/store";
-import { planIncludesReplay } from "../../lib/subscription/planUtils";
+import { planIncludesReplay, planIncludesStandardFeatures } from "../../lib/subscription/planUtils";
import { useStripeSubscription } from "../../lib/subscription/useStripeSubscription";
import { isValidDomain, normalizeDomain } from "../../lib/utils";
@@ -89,12 +89,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa
const setToggle = (key: ToggleKey, checked: boolean) => setToggles(prev => ({ ...prev, [key]: checked }));
const sessionReplayDisabled = !planIncludesReplay(subscription) && IS_CLOUD;
- const standardFeaturesDisabled =
- !subscription?.planName.includes("custom") &&
- !subscription?.planName.includes("standard") &&
- !subscription?.planName.includes("pro") &&
- !subscription?.planName.includes("appsumo") &&
- IS_CLOUD;
+ const standardFeaturesDisabled = !planIncludesStandardFeatures(subscription) && IS_CLOUD;
const privacyToggles: CreateToggle[] = [
{
diff --git a/client/src/app/settings/billing/layout.tsx b/client/src/app/settings/billing/layout.tsx
index 4348aba1c..d4db3d0a5 100644
--- a/client/src/app/settings/billing/layout.tsx
+++ b/client/src/app/settings/billing/layout.tsx
@@ -1,33 +1,31 @@
"use client";
import { useExtracted } from "next-intl";
-import { authClient } from "../../../lib/auth";
+import { useOrganizationAccess } from "../../../hooks/useOrganizationAccess";
+import { OrganizationAccessGate } from "../components/OrganizationAccessGate";
export default function BillingLayout({ 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 (
{t("Billing")}
-
- {t("Manage your subscription and billing.")}
-
+
{t("Manage your subscription and billing.")}
- {isMember ? (
-
- {t("You don't have permission to view subscription settings.")}
-
- ) : (
+
{children}
- )}
+
);
}
diff --git a/client/src/app/settings/billing/page.tsx b/client/src/app/settings/billing/page.tsx
index 707467f59..98d9d0a96 100644
--- a/client/src/app/settings/billing/page.tsx
+++ b/client/src/app/settings/billing/page.tsx
@@ -7,6 +7,7 @@ import { useStripeSubscription } from "../../../lib/subscription/useStripeSubscr
import { NoOrganization } from "../../../components/NoOrganization";
import { ExpiredTrialPlan } from "../../../components/subscription/ExpiredTrialPlan";
import { useSetPageTitle } from "../../../hooks/useSetPageTitle";
+import { useOrganizationAccess } from "../../../hooks/useOrganizationAccess";
import { FreePlan } from "../../../components/subscription/FreePlan";
import { OverridePlan } from "../../../components/subscription/OverridePlan";
import { CustomPlan } from "../../../components/subscription/CustomPlan";
@@ -23,6 +24,7 @@ export default function OrganizationBillingPage() {
const { data: activeOrg, isPending } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
+ const access = useOrganizationAccess();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -31,10 +33,6 @@ export default function OrganizationBillingPage() {
}
}, [session?.user?.email]);
- // Check if the current user is an owner by looking at the members in the active organization
- const currentUserMember = activeOrg?.members?.find(member => member.userId === session?.user?.id);
- const isOwner = currentUserMember?.role === "owner";
-
const isLoading = isLoadingSubscription || isPending;
// Determine which plan to display
@@ -43,7 +41,7 @@ export default function OrganizationBillingPage() {
return ;
}
- if (!isOwner) {
+ if (!access.decisions.manageSubscription.allowed) {
return (
diff --git a/client/src/app/settings/components/OrganizationAccessGate.test.tsx b/client/src/app/settings/components/OrganizationAccessGate.test.tsx
new file mode 100644
index 000000000..ffe8aef67
--- /dev/null
+++ b/client/src/app/settings/components/OrganizationAccessGate.test.tsx
@@ -0,0 +1,57 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { OrganizationAccessGate } from "./OrganizationAccessGate";
+
+const messages = {
+ deniedMessage: "Access denied",
+ errorMessage: "Could not check access",
+ loadingMessage: "Checking access",
+ noOrganizationMessage: "Select an Organization",
+ onRetry: vi.fn(),
+ retryLabel: "Try again",
+};
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("OrganizationAccessGate", () => {
+ it.each(["membership-pending", "membership-missing"] as const)(
+ "does not mount protected children when access is %s",
+ reason => {
+ render(
+
+ Protected settings
+
+ );
+
+ expect(screen.queryByText("Protected settings")).toBeNull();
+ }
+ );
+
+ it("mounts protected children only after access is allowed", () => {
+ render(
+
+ Protected settings
+
+ );
+
+ expect(screen.getByText("Protected settings")).toBeTruthy();
+ });
+
+ it("distinguishes an access-query failure from a permission denial", () => {
+ const onRetry = vi.fn();
+ render(
+
+ Protected settings
+
+ );
+
+ expect(screen.getByText("Could not check access")).toBeTruthy();
+ expect(screen.queryByText("Access denied")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+});
diff --git a/client/src/app/settings/components/OrganizationAccessGate.tsx b/client/src/app/settings/components/OrganizationAccessGate.tsx
new file mode 100644
index 000000000..0763237d6
--- /dev/null
+++ b/client/src/app/settings/components/OrganizationAccessGate.tsx
@@ -0,0 +1,58 @@
+import { NoOrganization } from "@/components/NoOrganization";
+import { Button } from "@/components/ui/button";
+import { OrganizationAccessDecision } from "@/lib/organizationAccess";
+
+interface OrganizationAccessGateProps {
+ children: React.ReactNode;
+ decision: OrganizationAccessDecision;
+ deniedMessage: string;
+ errorMessage: string;
+ loadingMessage: string;
+ noOrganizationMessage: string;
+ onRetry: () => void;
+ retryLabel: string;
+}
+
+export function OrganizationAccessGate({
+ children,
+ decision,
+ deniedMessage,
+ errorMessage,
+ loadingMessage,
+ noOrganizationMessage,
+ onRetry,
+ retryLabel,
+}: OrganizationAccessGateProps) {
+ if (decision.allowed) {
+ return children;
+ }
+
+ if (decision.reason === "active-organization-pending" || decision.reason === "membership-pending") {
+ return (
+
+ );
+ }
+
+ if (decision.reason === "no-active-organization") {
+ return
;
+ }
+
+ if (decision.reason === "active-organization-error" || decision.reason === "membership-error") {
+ return (
+
+ {errorMessage}
+
+ {retryLabel}
+
+
+ );
+ }
+
+ 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}
/>
- handleAddTag(newTagInput)}
- disabled={!newTagInput.trim()}
- >
+ handleAddTag(newTagInput)} disabled={!newTagInput.trim()}>
{t("Add")}
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 (
-
+
{isPlaying ? (
) : (
@@ -50,7 +59,7 @@ export const ReplayPlayerControls = memo(function ReplayPlayerControls({
0 ? (currentTime / duration) * 100 : 0]}
- onValueChange={onSliderChange}
+ onValueChange={value => scrubTo(value[0])}
max={100}
step={0.1}
activityPeriods={activityPeriods}
@@ -63,7 +72,7 @@ export const ReplayPlayerControls = memo(function ReplayPlayerControls({
{formatTime(currentTime)} / {formatTime(duration)}
-
+ changePlaybackSpeed(Number(speed))}>
@@ -76,11 +85,18 @@ export const ReplayPlayerControls = memo(function ReplayPlayerControls({
{!isDrawer && (
- setReplayDrawerOpen(true)}>
-
-
+ <>
+ setReplayDrawerOpen(true)}>
+
+
+
+ >
)}
-
);
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") {