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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,5 +106,12 @@
|
||||
"useThis": "{{language}}を自分の言語として使用",
|
||||
"dismiss": "閉じる"
|
||||
}
|
||||
},
|
||||
"nda": {
|
||||
"unsignedBanner": {
|
||||
"message": "非公開ドキュメントにアクセスするには秘密保持契約に署名してください。",
|
||||
"sign": "確認して署名",
|
||||
"dismiss": "閉じる"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,5 +106,12 @@
|
||||
"useThis": "표시 언어({{language}})를 내 언어로 사용",
|
||||
"dismiss": "닫기"
|
||||
}
|
||||
},
|
||||
"nda": {
|
||||
"unsignedBanner": {
|
||||
"message": "비공개 문서에 접근하려면 비밀 유지 계약에 서명하세요.",
|
||||
"sign": "검토 후 서명",
|
||||
"dismiss": "닫기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,5 +106,12 @@
|
||||
"useThis": "Використовувати {{language}} як мою мову",
|
||||
"dismiss": "Закрити"
|
||||
}
|
||||
},
|
||||
"nda": {
|
||||
"unsignedBanner": {
|
||||
"message": "Підпишіть NDA, щоб отримати доступ до приватних документів.",
|
||||
"sign": "Переглянути та підписати",
|
||||
"dismiss": "Закрити"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,5 +106,12 @@
|
||||
"useThis": "将{{language}}设为我的语言",
|
||||
"dismiss": "关闭"
|
||||
}
|
||||
},
|
||||
"nda": {
|
||||
"unsignedBanner": {
|
||||
"message": "签署保密协议以访问私有文档。",
|
||||
"sign": "查看并签署",
|
||||
"dismiss": "关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user