Show unsigned NDA banner in the portal shell

Signed-in users with an incomplete portal NDA need a clear
path to review and sign without waiting for a private
document export to fail the gate.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-30 15:05:34 +02:00
parent 320d5ee03c
commit 7f7c836e57
17 changed files with 254 additions and 0 deletions

View File

@@ -106,5 +106,12 @@
"useThis": "{{language}} als meine Sprache verwenden",
"dismiss": "Schließen"
}
},
"nda": {
"unsignedBanner": {
"message": "Unterschreiben Sie die Geheimhaltungsvereinbarung, um auf private Dokumente zuzugreifen.",
"sign": "Prüfen und unterschreiben",
"dismiss": "Schließen"
}
}
}

View File

@@ -106,5 +106,12 @@
"pagination": {
"previous": "Previous page",
"next": "Next page"
},
"nda": {
"unsignedBanner": {
"message": "Sign the NDA to access private documents.",
"sign": "Review and sign",
"dismiss": "Dismiss"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Usar {{language}} como mi idioma",
"dismiss": "Descartar"
}
},
"nda": {
"unsignedBanner": {
"message": "Firme el acuerdo de confidencialidad para acceder a los documentos privados.",
"sign": "Revisar y firmar",
"dismiss": "Cerrar"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Utiliser {{language}} comme ma langue",
"dismiss": "Ignorer"
}
},
"nda": {
"unsignedBanner": {
"message": "Signez l'accord de confidentialité pour accéder aux documents privés.",
"sign": "Lire et signer",
"dismiss": "Fermer"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Gunakan {{language}} sebagai bahasa saya",
"dismiss": "Tutup"
}
},
"nda": {
"unsignedBanner": {
"message": "Tandatangani perjanjian kerahasiaan untuk mengakses dokumen pribadi.",
"sign": "Tinjau dan tanda tangani",
"dismiss": "Tutup"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Usa {{language}} come mia lingua",
"dismiss": "Ignora"
}
},
"nda": {
"unsignedBanner": {
"message": "Firmi l'accordo di riservatezza per accedere ai documenti privati.",
"sign": "Leggi e firma",
"dismiss": "Chiudi"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "{{language}}を自分の言語として使用",
"dismiss": "閉じる"
}
},
"nda": {
"unsignedBanner": {
"message": "非公開ドキュメントにアクセスするには秘密保持契約に署名してください。",
"sign": "確認して署名",
"dismiss": "閉じる"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "표시 언어({{language}})를 내 언어로 사용",
"dismiss": "닫기"
}
},
"nda": {
"unsignedBanner": {
"message": "비공개 문서에 접근하려면 비밀 유지 계약에 서명하세요.",
"sign": "검토 후 서명",
"dismiss": "닫기"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Użyj {{language}} jako mojego języka",
"dismiss": "Zamknij"
}
},
"nda": {
"unsignedBanner": {
"message": "Podpisz umowę o zachowaniu poufności, aby uzyskać dostęp do prywatnych dokumentów.",
"sign": "Przejrzyj i podpisz",
"dismiss": "Zamknij"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Usar {{language}} como o meu idioma",
"dismiss": "Ignorar"
}
},
"nda": {
"unsignedBanner": {
"message": "Assine o acordo de confidencialidade para aceder aos documentos privados.",
"sign": "Rever e assinar",
"dismiss": "Fechar"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "{{language}} dilini benim dilim olarak kullan",
"dismiss": "Kapat"
}
},
"nda": {
"unsignedBanner": {
"message": "Özel belgelere erişmek için gizlilik sözleşmesini imzalayın.",
"sign": "İncele ve imzala",
"dismiss": "Kapat"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "Використовувати {{language}} як мою мову",
"dismiss": "Закрити"
}
},
"nda": {
"unsignedBanner": {
"message": "Підпишіть NDA, щоб отримати доступ до приватних документів.",
"sign": "Переглянути та підписати",
"dismiss": "Закрити"
}
}
}

View File

@@ -106,5 +106,12 @@
"useThis": "将{{language}}设为我的语言",
"dismiss": "关闭"
}
},
"nda": {
"unsignedBanner": {
"message": "签署保密协议以访问私有文档。",
"sign": "查看并签署",
"dismiss": "关闭"
}
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { FileTextIcon, XIcon } from "@phosphor-icons/react";
import { Link } from "@probo/ui/src/v2/Button/Link";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { useLocalizedPath } from "#/lib/i18n/useLocale";
import type { UnsignedNDACallout_compliancePortal$key } from "./__generated__/UnsignedNDACallout_compliancePortal.graphql";
import { unsignedNDACallout } from "./variants";
const unsignedNDACalloutFragment = graphql`
fragment UnsignedNDACallout_compliancePortal on CompliancePortal {
nonDisclosureAgreement {
viewerSignature {
status
}
}
}
`;
interface UnsignedNDACalloutProps {
compliancePortalKey: UnsignedNDACallout_compliancePortal$key;
}
// Full-bleed notice when a signed-in user still needs to complete the portal
// NDA. Dismissed state is React-only (no localStorage/cookies).
export function UnsignedNDACallout({ compliancePortalKey }: UnsignedNDACalloutProps) {
const { t } = useTranslation();
const localizedPath = useLocalizedPath();
const portal = useFragment(unsignedNDACalloutFragment, compliancePortalKey);
const [dismissed, setDismissed] = useState(false);
const signature = portal.nonDisclosureAgreement?.viewerSignature;
const needsSignature = signature != null && signature.status !== "COMPLETED";
const visible = !dismissed && needsSignature;
if (!visible) {
return null;
}
const ndaHref = `${localizedPath("/nda")}?continue=${encodeURIComponent(window.location.href)}`;
const slots = unsignedNDACallout();
return (
<aside className={slots.root()} role="status">
<div className={slots.content()}>
<FileTextIcon weight="fill" className={slots.icon()} aria-hidden />
<Text size={2} color="neutral" highContrast className={slots.message()}>
{t("nda.unsignedBanner.message")}
</Text>
<IconButton
size={1}
variant="ghost"
color="neutral"
aria-label={t("nda.unsignedBanner.dismiss")}
className={slots.dismissMobile()}
onClick={() => setDismissed(true)}
>
<XIcon />
</IconButton>
</div>
<div className={slots.actions()}>
<Link
to={ndaHref}
size={1}
variant="solid"
color="gold"
className={slots.cta()}
>
{t("nda.unsignedBanner.sign")}
</Link>
<IconButton
size={1}
variant="ghost"
color="neutral"
aria-label={t("nda.unsignedBanner.dismiss")}
className={slots.dismissDesktop()}
onClick={() => setDismissed(true)}
>
<XIcon />
</IconButton>
</div>
</aside>
);
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Full-bleed unsigned-NDA banner under the TopBar: message row + CTA/dismiss,
// stacking on small screens like LocaleMismatchCallout.
export const unsignedNDACallout = tv({
slots: {
root: "flex w-full items-center gap-3 bg-gold-3 px-8 py-2.5 max-md:flex-col max-md:items-stretch max-md:gap-3 max-md:px-4 max-md:py-3",
content: "flex min-w-0 flex-1 items-start gap-2",
icon: "mt-0.5 size-4 shrink-0 text-gold-11",
message: "min-w-0 flex-1",
dismissMobile: "md:hidden",
actions: "flex shrink-0 items-center gap-2 max-md:flex-col max-md:items-stretch",
cta: "max-md:w-full",
dismissDesktop: "max-md:hidden",
},
});

View File

@@ -26,6 +26,7 @@ import { Outlet, useMatch } from "react-router";
import { LocaleMismatchCallout } from "#/components/LocaleMismatchCallout";
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
import { TopBar } from "#/components/TopBar/TopBar";
import { UnsignedNDACallout } from "#/components/UnsignedNDACallout/UnsignedNDACallout";
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
import { useEnsureViewerLocale } from "#/lib/i18n/useEnsureViewerLocale";
import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider";
@@ -39,6 +40,9 @@ export const mainLayoutQuery = graphql`
...LocaleMismatchCallout_identity
...useEnsureViewerLocale_identity
}
currentCompliancePortal {
...UnsignedNDACallout_compliancePortal
}
...TopBar_query
...SubscribeDialogProvider_query
}
@@ -74,6 +78,9 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
{data.viewer != null
? <LocaleMismatchCallout identityKey={data.viewer} />
: null}
{data.viewer != null && data.currentCompliancePortal != null
? <UnsignedNDACallout compliancePortalKey={data.currentCompliancePortal} />
: null}
<div
className={
isDocumentViewer

View File

@@ -66,6 +66,7 @@ func newNDADirective(
logger.ErrorCtx(ctx, "cannot check target visibility for NDA gate", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if skip {
return next(ctx)
}
@@ -123,6 +124,7 @@ func shouldSkipNDAForPublic(
if !ok {
return false, nil
}
return isPublicDocument(ctx, visitorSvc, scope, organizationID, input.DocumentID)
case "requestDocumentAccess":
@@ -130,6 +132,7 @@ func shouldSkipNDAForPublic(
if !ok {
return false, nil
}
return isPublicDocument(ctx, visitorSvc, scope, organizationID, input.DocumentID)
case "exportReportPDF":
@@ -137,6 +140,7 @@ func shouldSkipNDAForPublic(
if !ok {
return false, nil
}
return isPublicReport(ctx, visitorSvc, scope, input.ReportID)
case "requestReportAccess":
@@ -144,6 +148,7 @@ func shouldSkipNDAForPublic(
if !ok {
return false, nil
}
return isPublicReport(ctx, visitorSvc, scope, input.ReportID)
case "exportCompliancePortalFile":
@@ -151,6 +156,7 @@ func shouldSkipNDAForPublic(
if !ok {
return false, nil
}
return isPublicPortalFile(ctx, visitorSvc, scope, organizationID, input.CompliancePortalFileID)
case "requestCompliancePortalFileAccess":
@@ -158,6 +164,7 @@ func shouldSkipNDAForPublic(
if !ok {
return false, nil
}
return isPublicPortalFile(ctx, visitorSvc, scope, organizationID, input.CompliancePortalFileID)
default:
@@ -177,6 +184,7 @@ func isPublicDocument(
if isMissingDocument(err) {
return true, nil
}
return false, fmt.Errorf("cannot load document: %w", err)
}
@@ -194,6 +202,7 @@ func isPublicReport(
if errors.Is(err, coredata.ErrResourceNotFound) {
return true, nil
}
return false, fmt.Errorf("cannot load audit: %w", err)
}
@@ -214,6 +223,7 @@ func isPublicPortalFile(
errors.Is(err, coredata.ErrResourceNotFound) {
return true, nil
}
return false, fmt.Errorf("cannot load portal file: %w", err)
}
@@ -226,6 +236,8 @@ func isMissingDocument(err error) bool {
errors.Is(err, coredata.ErrResourceNotFound) {
return true
}
_, ok := errors.AsType[*visitor.ErrDocumentArchived](err)
return ok
}