Keep document title on locked viewer

Unauthorized visitors still need the document identity and a
way back to the list; only the preview toolbar is withheld,
with the access CTA replacing the file body.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-29 17:20:10 +02:00
parent 98e1177aab
commit e9d0825b5e
2 changed files with 131 additions and 103 deletions

View File

@@ -22,7 +22,6 @@ import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery } from "react-relay";
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql"; import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
import { DocumentLocked } from "./_components/DocumentLocked";
import { DocumentViewer } from "./_components/DocumentViewer"; import { DocumentViewer } from "./_components/DocumentViewer";
import { useAccessRequest } from "./_lib/useAccessRequest"; import { useAccessRequest } from "./_lib/useAccessRequest";
import type { DocumentKind } from "./_lib/useDocumentExport"; import type { DocumentKind } from "./_lib/useDocumentExport";
@@ -76,8 +75,8 @@ function resolveNode(node: DocumentViewerPageQuery["response"]["aliasedNode"]):
} }
// Full-page viewer for a single document/file/report resolved by its alias. It // Full-page viewer for a single document/file/report resolved by its alias. It
// exports the (watermarked) bytes and renders them; unauthorized visitors get a // exports the (watermarked) bytes and renders them; unauthorized visitors keep
// locked state instead. // the title header and see a locked empty state in place of the preview.
export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) { export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) {
const data = usePreloadedQuery<DocumentViewerPageQuery>(documentViewerPageQuery, queryRef); const data = usePreloadedQuery<DocumentViewerPageQuery>(documentViewerPageQuery, queryRef);
const node = resolveNode(data.aliasedNode); const node = resolveNode(data.aliasedNode);
@@ -85,7 +84,12 @@ export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) {
const { requestAccess, isRequesting } = useAccessRequest(node.kind, node.id); const { requestAccess, isRequesting } = useAccessRequest(node.kind, node.id);
if (!node.isAuthorized) { if (!node.isAuthorized) {
return <DocumentLocked onGetAccess={requestAccess} isRequesting={isRequesting} />; return (
<DocumentViewer
title={node.title}
locked={{ onGetAccess: requestAccess, isRequesting }}
/>
);
} }
return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />; return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />;

View File

@@ -43,6 +43,7 @@ import { useLocalizedPath } from "#/lib/i18n/useLocale";
import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri"; import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
import { DocumentDownloadFallback } from "./DocumentDownloadFallback"; import { DocumentDownloadFallback } from "./DocumentDownloadFallback";
import { DocumentLocked } from "./DocumentLocked";
import type { PdfPreviewHandle } from "./PdfPreview"; import type { PdfPreviewHandle } from "./PdfPreview";
import { PdfPreview } from "./PdfPreview"; import { PdfPreview } from "./PdfPreview";
import { documentViewer } from "./variants"; import { documentViewer } from "./variants";
@@ -54,20 +55,33 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
interface DocumentViewerLocked {
// Requests access for the locked resource (prompting sign-in first when
// needed).
onGetAccess: () => void;
// Whether the access request is in flight.
isRequesting: boolean;
}
interface DocumentViewerProps { interface DocumentViewerProps {
// The document/file/report display name. // The document/file/report display name.
title: string; title: string;
// The exported base64 data URI, or null while it is still loading. // The exported base64 data URI, or null while it is still loading. Omitted
dataUri: string | null; // when the viewer is locked.
// File name used when downloading. dataUri?: string | null;
downloadName: string; // File name used when downloading. Omitted when the viewer is locked.
downloadName?: string;
// When set, the header keeps the title (no toolbar) and the body shows the
// locked empty state with a Get Access CTA instead of the file preview.
locked?: DocumentViewerLocked;
} }
// Full-page document viewer: a header band with the title and a toolbar // Full-page document viewer: a header band with the title and a toolbar
// (page navigation + zoom for PDFs, copy link, download) above the scrollable // (page navigation + zoom for PDFs, copy link, download) above the scrollable
// body. PDFs render with react-pdf, images inline, and anything else offers a // body. PDFs render with react-pdf, images inline, and anything else offers a
// download. // download. Locked visitors keep the title header and see a Get Access CTA in
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) { // place of the preview.
export function DocumentViewer({ title, dataUri = null, downloadName = title, locked }: DocumentViewerProps) {
const { t } = useTranslation("documents"); const { t } = useTranslation("documents");
const toast = Toast.useToastManager(); const toast = Toast.useToastManager();
const localizedPath = useLocalizedPath(); const localizedPath = useLocalizedPath();
@@ -77,7 +91,8 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const mimeType = dataUri ? dataUriMimeType(dataUri) : null; const isLocked = locked != null;
const mimeType = !isLocked && dataUri ? dataUriMimeType(dataUri) : null;
const isPdf = mimeType === "application/pdf"; const isPdf = mimeType === "application/pdf";
const isImage = mimeType?.startsWith("image/") ?? false; const isImage = mimeType?.startsWith("image/") ?? false;
@@ -104,7 +119,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
return ( return (
<div className={slots.root()}> <div className={slots.root()}>
<HeaderBand flushBottomSpace> <HeaderBand flushBottomSpace={!isLocked}>
<div className={slots.header()}> <div className={slots.header()}>
<Link to={localizedPath("/documents")} variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}> <Link to={localizedPath("/documents")} variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
{t("viewer.back")} {t("viewer.back")}
@@ -112,108 +127,117 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
<Heading level={1} size={7} weight="medium" highContrast className="truncate"> <Heading level={1} size={7} weight="medium" highContrast className="truncate">
{title} {title}
</Heading> </Heading>
<div className={slots.toolbar()}> {!isLocked && (
<div className={slots.toolbarStart()}> <div className={slots.toolbar()}>
{isPdf && ( <div className={slots.toolbarStart()}>
<> {isPdf && (
<div className={slots.controls()}> <>
<IconButton <div className={slots.controls()}>
variant="ghost" <IconButton
color="neutral" variant="ghost"
aria-label={t("common.previousPage")} color="neutral"
disabled={currentPage <= 1} aria-label={t("common.previousPage")}
onClick={() => movePage(-1)} disabled={currentPage <= 1}
> onClick={() => movePage(-1)}
<CaretLeftIcon /> >
</IconButton> <CaretLeftIcon />
<Text size={2} color="neutral"> </IconButton>
{t("common.pageOf", { current: currentPage, total: numPages })} <Text size={2} color="neutral">
</Text> {t("common.pageOf", { current: currentPage, total: numPages })}
<IconButton </Text>
variant="ghost" <IconButton
color="neutral" variant="ghost"
aria-label={t("common.nextPage")} color="neutral"
disabled={currentPage >= numPages} aria-label={t("common.nextPage")}
onClick={() => movePage(1)} disabled={currentPage >= numPages}
> onClick={() => movePage(1)}
<CaretRightIcon /> >
</IconButton> <CaretRightIcon />
</div> </IconButton>
<Separator orientation="vertical" className={slots.separator()} /> </div>
<div className={slots.controls()}> <Separator orientation="vertical" className={slots.separator()} />
<IconButton <div className={slots.controls()}>
variant="ghost" <IconButton
color="neutral" variant="ghost"
aria-label={t("common.zoomOut")} color="neutral"
onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))} aria-label={t("common.zoomOut")}
> onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))}
<MagnifyingGlassMinusIcon /> >
</IconButton> <MagnifyingGlassMinusIcon />
<Text size={2} color="neutral"> </IconButton>
{`${Math.round(scale * 100)}%`} <Text size={2} color="neutral">
</Text> {`${Math.round(scale * 100)}%`}
<IconButton </Text>
variant="ghost" <IconButton
color="neutral" variant="ghost"
aria-label={t("common.zoomIn")} color="neutral"
onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))} aria-label={t("common.zoomIn")}
> onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))}
<MagnifyingGlassPlusIcon /> >
</IconButton> <MagnifyingGlassPlusIcon />
</div> </IconButton>
</> </div>
)} </>
)}
</div>
<div className={slots.actions()}>
<Button
variant="ghost"
color="neutral"
iconStart={<LinkSimpleIcon />}
onClick={handleCopyLink}
aria-label={t("viewer.copyLink")}
>
<span className={slots.actionLabel()}>{t("viewer.copyLink")}</span>
</Button>
<Separator orientation="vertical" className={slots.separator()} />
<Button
variant="ghost"
color="neutral"
iconStart={<DownloadSimpleIcon />}
disabled={dataUri == null}
onClick={handleDownload}
aria-label={t("viewer.download")}
>
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
</Button>
</div>
</div> </div>
<div className={slots.actions()}> )}
<Button
variant="ghost"
color="neutral"
iconStart={<LinkSimpleIcon />}
onClick={handleCopyLink}
aria-label={t("viewer.copyLink")}
>
<span className={slots.actionLabel()}>{t("viewer.copyLink")}</span>
</Button>
<Separator orientation="vertical" className={slots.separator()} />
<Button
variant="ghost"
color="neutral"
iconStart={<DownloadSimpleIcon />}
disabled={dataUri == null}
onClick={handleDownload}
aria-label={t("viewer.download")}
>
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
</Button>
</div>
</div>
</div> </div>
</HeaderBand> </HeaderBand>
<div className={slots.body()}> <div className={slots.body()}>
{dataUri == null {isLocked
? ( ? (
<div className={slots.stage()}> <DocumentLocked
<SpinnerGapIcon className={slots.spinner()} /> onGetAccess={locked.onGetAccess}
</div> isRequesting={locked.isRequesting}
/>
) )
: isPdf : dataUri == null
? ( ? (
<PdfPreview <div className={slots.stage()}>
ref={pdfRef} <SpinnerGapIcon className={slots.spinner()} />
file={dataUri} </div>
scale={scale}
onNumPages={setNumPages}
onVisiblePageChange={setCurrentPage}
/>
) )
: isImage : isPdf
? ( ? (
<div className={slots.imageStage()}> <PdfPreview
<img src={dataUri} alt={title} className={slots.image()} /> ref={pdfRef}
</div> file={dataUri}
scale={scale}
onNumPages={setNumPages}
onVisiblePageChange={setCurrentPage}
/>
) )
: <DocumentDownloadFallback onDownload={handleDownload} />} : isImage
? (
<div className={slots.imageStage()}>
<img src={dataUri} alt={title} className={slots.image()} />
</div>
)
: <DocumentDownloadFallback onDownload={handleDownload} />}
</div> </div>
</div> </div>
); );