Skip to content
Merged
161 changes: 102 additions & 59 deletions templates/clips/app/components/player/share-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export interface ShareRecordingPopoverProps {
animatedThumbnailUrl?: string | null;
isLoomRecording?: boolean;
hasPassword?: boolean;
/**
* Restricts the dialog to a bare copy-link control for viewers who can
* reshare a public/org clip's link but have no edit access: it skips
* `list-resource-shares` (which returns every individually-shared
* principal's email to any reader) and hides the Invite tab entirely.
*/
viewerReshareOnly?: boolean;
/** Trigger element rendered as the popover anchor (usually the Share button). */
children: ReactNode;
open?: boolean;
Expand Down Expand Up @@ -103,6 +110,7 @@ export function ShareRecordingPopover({
animatedThumbnailUrl,
isLoomRecording = false,
hasPassword,
viewerReshareOnly = false,
children,
open,
onOpenChange,
Expand All @@ -125,6 +133,7 @@ export function ShareRecordingPopover({
animatedThumbnailUrl={animatedThumbnailUrl}
isLoomRecording={isLoomRecording}
hasPassword={hasPassword}
viewerReshareOnly={viewerReshareOnly}
/>
</PopoverContent>
</Popover>
Expand All @@ -148,6 +157,7 @@ export function ShareRecordingDialog({
open,
onOpenChange,
hasPassword,
viewerReshareOnly = false,
}: ShareRecordingDialogProps) {
const t = useT();
return (
Expand All @@ -168,6 +178,7 @@ export function ShareRecordingDialog({
animatedThumbnailUrl={animatedThumbnailUrl}
isLoomRecording={isLoomRecording}
hasPassword={hasPassword}
viewerReshareOnly={viewerReshareOnly}
reserveCloseButton
/>
</DialogContent>
Expand All @@ -186,6 +197,7 @@ function ShareRecordingContent({
isLoomRecording = false,
reserveCloseButton = false,
hasPassword,
viewerReshareOnly = false,
}: {
recordingId: string;
recordingTitle?: string;
Expand All @@ -197,20 +209,34 @@ function ShareRecordingContent({
isLoomRecording?: boolean;
reserveCloseButton?: boolean;
hasPassword?: boolean;
viewerReshareOnly?: boolean;
}) {
const t = useT();
const sharesQuery = useActionQuery<SharesResponse>("list-resource-shares", {
resourceType: "recording",
resourceId: recordingId,
});
const sharesQuery = useActionQuery<SharesResponse>(
"list-resource-shares",
{ resourceType: "recording", resourceId: recordingId },
{ enabled: !viewerReshareOnly },
);

const data = sharesQuery.data;
const data = viewerReshareOnly ? undefined : sharesQuery.data;
const role = data?.role ?? initialRole;
const canManage = role === "owner" || role === "admin";
// Editors could always see (read-only) who a clip is shared with; only
// gate the Invite tab's mutation controls behind canManage. Commenters are
// grouped with plain viewers here -- neither can manage shares.
const canViewShares =
role === "owner" || role === "admin" || role === "editor";
const visibility =
(data?.visibility as Visibility | null | undefined) ??
initialVisibility ??
null;
// A plain viewer/commenter can't produce a working embed for a non-public
// clip (they have no way to make it public), so don't dangle the tab in
// front of them only to show an "ask the owner" dead end. Owner/admin/
// editor keep it regardless of visibility since they can flip to public
// from inside it.
const canEmbed = canViewShares || visibility === "public";
const tabCount = 1 + (canViewShares ? 1 : 0) + (canEmbed ? 1 : 0);

// Attribution `via` must be a stable non-PII id, never an email. The only
// owner id available client-side is the *current* session's userId, which is
Expand All @@ -234,20 +260,28 @@ function ShareRecordingContent({
defaultValue="link"
className={cn("min-w-0 px-4 py-3", reserveCloseButton && "pe-12")}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="link" className="gap-1.5">
<IconLink size={14} />
{t("shareDialog.link")}
</TabsTrigger>
<TabsTrigger value="invite" className="gap-1.5">
<IconMail size={14} />
{t("shareDialog.invite")}
</TabsTrigger>
<TabsTrigger value="embed" className="gap-1.5">
<IconCode size={14} />
{t("shareDialog.embed")}
</TabsTrigger>
</TabsList>
{tabCount > 1 ? (
<TabsList
className={`grid w-full ${tabCount === 3 ? "grid-cols-3" : "grid-cols-2"}`}
>
<TabsTrigger value="link" className="gap-1.5">
<IconLink size={14} />
{t("shareDialog.link")}
</TabsTrigger>
{canViewShares ? (
<TabsTrigger value="invite" className="gap-1.5">
<IconMail size={14} />
{t("shareDialog.invite")}
</TabsTrigger>
) : null}
{canEmbed ? (
<TabsTrigger value="embed" className="gap-1.5">
<IconCode size={14} />
{t("shareDialog.embed")}
</TabsTrigger>
) : null}
</TabsList>
) : null}

<TabsContent value="link" className="mt-3">
<LinkTab
Expand All @@ -262,34 +296,39 @@ function ShareRecordingContent({
animatedThumbnailUrl={animatedThumbnailUrl}
isLoomRecording={isLoomRecording}
hasPassword={hasPassword}
canViewShares={canViewShares}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Propagate viewer-only restrictions into LinkTab

viewerReshareOnly prevents the shares query and hides Invite, but it is not passed to LinkTab. For an org-visible viewer, initialVisibility makes the link state loaded, so LinkTab still runs create-recording-agent-link and renders the agent-sharing controls, allowing a bearer URL instead of only the intended human copy link. Pass the restriction through and suppress the agent-link mutation/controls (and other non-copy-only extras) in viewer-only mode.

Additional Info
The LinkTab effect invokes the mutation whenever visibility is non-public; org visibility therefore follows that path even though ShareRecordingContent is in viewerReshareOnly mode.

Fix in Builder

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, real gap — viewerReshareOnly only stopped list-resource-shares and the Invite tab, but LinkTab's own effect independently calls create-recording-agent-link whenever !isPublic, which includes org-visibility reshare viewers, minting a scoped bearer-token URL for them.

Fixed in 827f365: threaded viewerReshareOnly down into LinkTab, added it to the effect's early-return guard so the agent link is never minted in that mode, and hid the whole "share with agents" block (!isPublic && !viewerReshareOnly) since there's no bearer-token-minting capability to offer a bare-link-only viewer anyway.

</TabsContent>

<TabsContent value="invite" className="mt-3">
<SharePeopleTab
resourceType="recording"
resourceId={recordingId}
resourceUrl={absoluteAppUrl(`/r/${recordingId}`)}
sharesQuery={sharesQuery}
canManage={canManage}
roleCopy={{
commenter: {
label: t("shareUi.recordingCommenter.label"),
description: t("shareUi.recordingCommenter.description"),
},
}}
/>
</TabsContent>

<TabsContent value="embed" className="mt-3">
<ClipsEmbedConfigurator
recordingId={recordingId}
sharesQuery={sharesQuery}
visibility={visibility}
canManage={canManage}
ownerViaId={ownerViaId}
/>
</TabsContent>
{canViewShares ? (
<TabsContent value="invite" className="mt-3">
<SharePeopleTab
resourceType="recording"
resourceId={recordingId}
resourceUrl={absoluteAppUrl(`/r/${recordingId}`)}
sharesQuery={sharesQuery}
canManage={canManage}
roleCopy={{
commenter: {
label: t("shareUi.recordingCommenter.label"),
description: t("shareUi.recordingCommenter.description"),
},
}}
/>
</TabsContent>
) : null}

{canEmbed ? (
<TabsContent value="embed" className="mt-3">
<ClipsEmbedConfigurator
recordingId={recordingId}
sharesQuery={sharesQuery}
visibility={visibility}
canManage={canManage}
ownerViaId={ownerViaId}
/>
</TabsContent>
) : null}
</Tabs>
</>
);
Expand All @@ -311,6 +350,7 @@ function LinkTab({
animatedThumbnailUrl,
isLoomRecording: isLoomRecordingProp,
hasPassword,
canViewShares,
}: {
recordingId: string;
recordingTitle?: string;
Expand All @@ -323,6 +363,7 @@ function LinkTab({
animatedThumbnailUrl?: string | null;
isLoomRecording?: boolean;
hasPassword?: boolean;
canViewShares: boolean;
}) {
const t = useT();
const { setResourceVisibility, isPending } = useResourceVisibilityMutation(
Expand Down Expand Up @@ -460,21 +501,23 @@ function LinkTab({

return (
<div className="space-y-4">
{visibility ? (
<GeneralAccessSelect
visibility={visibility}
canManage={canManage}
isPending={visibilityPending}
onChange={(next) => setResourceVisibility(next)}
publicDescription={t("shareDialog.publicDescription")}
showDescription={false}
/>
) : (
<div className="space-y-2" aria-hidden>
<div className="h-3 w-24 animate-pulse rounded bg-muted" />
<div className="h-12 w-full animate-pulse rounded bg-muted" />
</div>
)}
{canViewShares ? (
visibility ? (
<GeneralAccessSelect
visibility={visibility}
canManage={canManage}
isPending={visibilityPending}
onChange={(next) => setResourceVisibility(next)}
publicDescription={t("shareDialog.publicDescription")}
showDescription={false}
/>
) : (
<div className="space-y-2" aria-hidden>
<div className="h-3 w-24 animate-pulse rounded bg-muted" />
<div className="h-12 w-full animate-pulse rounded bg-muted" />
</div>
)
) : null}

<CopyField
label={
Expand Down
16 changes: 15 additions & 1 deletion templates/clips/app/routes/r.$recordingId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,18 @@ export default function RecordingPage() {
const canDownloadRecording = Boolean(
recording?.enableDownloads && recording.videoUrl && !isLoomEmbedBacked,
);
// Mirrors the /share/:shareId reshare restriction (same public/org scope):
// a plain viewer of a public or org clip must not trigger
// `list-resource-shares` (any read access is enough to call it, and its
// response includes every individually-shared principal's email) or see a
// raw video download/open action independent of `enableDownloads`.
const viewerReshareOnly =
(role === "viewer" || role === "commenter") &&
(recording?.visibility === "public" || recording?.visibility === "org");
const shareVideoUrl =
canDownloadRecording || isLoomEmbedBacked
? (recording?.videoUrl ?? null)
: null;
const downloadRecording = useCallback(async () => {
if (!recording?.videoUrl) return;
setDownloading(true);
Expand Down Expand Up @@ -983,6 +995,7 @@ export default function RecordingPage() {
initialVisibility={recording.visibility}
initialRole={role}
hasPassword={Boolean(recording.hasPassword)}
viewerReshareOnly={viewerReshareOnly}
>
<Button className="shrink-0 gap-1.5" size="sm">
{recording.visibility !== "public" ? (
Expand Down Expand Up @@ -1542,11 +1555,12 @@ export default function RecordingPage() {
recordingTitle={recording.title}
initialVisibility={recording.visibility}
initialRole={role}
videoUrl={recording.videoUrl}
videoUrl={shareVideoUrl}
thumbnailUrl={recording.thumbnailUrl}
animatedThumbnailUrl={recording.animatedThumbnailUrl}
isLoomRecording={isLoomEmbedBacked}
hasPassword={Boolean(recording.hasPassword)}
viewerReshareOnly={viewerReshareOnly}
>
<Button
className="shrink-0 gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90"
Expand Down
18 changes: 16 additions & 2 deletions templates/clips/app/routes/share.$shareId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,15 @@ export default function ShareRoute() {
viewerRole === "editor" ||
viewerRole === "commenter";
const viewerIsOwner = Boolean(dataQ.data?.data?.viewer?.isOwner);
const canReshareLink =
(viewerRole === "viewer" || viewerRole === "commenter") &&
(recording?.visibility === "public" || recording?.visibility === "org");
// A plain viewer only gets a copy-link control: it must not trigger
// `list-resource-shares` (any read access is enough to call it, and its
// response includes every individually-shared principal's email) and must
// not surface the raw video download/open action independent of
// `enableDownloads`.
const viewerReshareOnly = canReshareLink && !viewerCanEdit;
const viewerCanOpenDashboard = Boolean(
dataQ.data?.data?.viewer?.canOpenDashboard,
);
Expand Down Expand Up @@ -894,6 +903,10 @@ export default function ShareRoute() {
const canDownloadRecording = Boolean(
recording.enableDownloads && recording.videoUrl && !isLoomEmbedBacked,
);
// Loom-backed clips only ever get an "open player" link (not a raw
// download), so they're exempt from the enableDownloads gate here.
const shareVideoUrl =
canDownloadRecording || isLoomEmbedBacked ? recording.videoUrl : null;

return (
<div className="flex min-h-screen max-w-full flex-col overflow-x-hidden bg-background text-foreground lg:h-screen lg:flex-row lg:overflow-hidden">
Expand Down Expand Up @@ -990,17 +1003,18 @@ export default function ShareRoute() {
onDeleted={() => navigate("/library", { replace: true })}
/>
) : null}
{viewerCanEdit ? (
{viewerCanEdit || canReshareLink ? (
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
<ShareRecordingPopover
recordingId={recording.id}
recordingTitle={recording.title}
initialVisibility={recording.visibility}
initialRole={viewerIsOwner ? "owner" : undefined}
videoUrl={recording.videoUrl}
videoUrl={shareVideoUrl}
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
thumbnailUrl={recording.thumbnailUrl}
animatedThumbnailUrl={recording.animatedThumbnailUrl}
isLoomRecording={isLoomEmbedBacked}
hasPassword={Boolean(recording.hasPassword)}
viewerReshareOnly={viewerReshareOnly}
>
<Button size="sm" className="shrink-0 gap-1.5">
{recording.visibility !== "public" ? (
Expand Down
Loading