From 855a48679024803e058a249d9083f3c270d3b18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 20 Jul 2026 15:23:44 +0200 Subject: [PATCH] Wire subscribe-to-updates in compliance portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visitors can subscribe after sign-in via the Updates CTA or user menu. Also add trust signOut so Log out works from the menu. Signed-off-by: Émile Ré --- .../compliance-portal/src/_locales/en-US.json | 5 +- .../compliance-portal/src/_locales/fr-FR.json | 5 +- .../SubscribeDialog/SubscribeDialog.tsx | 131 ++++++++++++++++++ .../src/components/TopBar/TopBarUserMenu.tsx | 41 +++++- .../src/lib/auth/continueUrl.ts | 10 ++ .../src/lib/auth/useSignOut.ts | 51 +++++++ .../mailingList/SubscribeDialogProvider.tsx | 131 ++++++++++++++++++ .../lib/mailingList/subscribeDialogContext.ts | 43 ++++++ .../mailingList/useSubscribeToMailingList.ts | 67 +++++++++ .../useUnsubscribeFromMailingList.ts | 44 ++++++ .../src/pages/MainLayout.tsx | 16 ++- .../_components/UpdatesSubscribeButton.tsx | 19 ++- .../src/pages/updates/_locales/en-US.json | 10 ++ .../src/pages/updates/_locales/fr-FR.json | 10 ++ packages/ui/src/v2/Dropdown/DropdownItem.tsx | 5 +- packages/ui/src/v2/Dropdown/variants.ts | 4 + pkg/server/api/trust/v1/auth_resolvers.go | 21 +++ pkg/server/api/trust/v1/graphql/auth.graphql | 5 + 18 files changed, 601 insertions(+), 17 deletions(-) create mode 100644 apps/compliance-portal/src/components/SubscribeDialog/SubscribeDialog.tsx create mode 100644 apps/compliance-portal/src/lib/auth/useSignOut.ts create mode 100644 apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx create mode 100644 apps/compliance-portal/src/lib/mailingList/subscribeDialogContext.ts create mode 100644 apps/compliance-portal/src/lib/mailingList/useSubscribeToMailingList.ts create mode 100644 apps/compliance-portal/src/lib/mailingList/useUnsubscribeFromMailingList.ts diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 2280ed2ef..58c13c777 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -10,7 +10,10 @@ } }, "userMenu": { - "signOut": "Sign out" + "subscribe": "Subscribe to updates", + "subscribed": "Subscribed to updates", + "signOut": "Sign out", + "signOutFailed": "Cannot sign out" }, "common": { "cancel": "Cancel", diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index bbe8c4f2e..b376b88c3 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -10,7 +10,10 @@ } }, "userMenu": { - "signOut": "Se déconnecter" + "subscribe": "S'abonner aux mises à jour", + "subscribed": "Abonné aux mises à jour", + "signOut": "Se déconnecter", + "signOutFailed": "Impossible de se déconnecter" }, "common": { "cancel": "Annuler", diff --git a/apps/compliance-portal/src/components/SubscribeDialog/SubscribeDialog.tsx b/apps/compliance-portal/src/components/SubscribeDialog/SubscribeDialog.tsx new file mode 100644 index 000000000..5969dd867 --- /dev/null +++ b/apps/compliance-portal/src/components/SubscribeDialog/SubscribeDialog.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { Button } from "@probo/ui/src/v2/Button/Button"; +import { Dialog } from "@probo/ui/src/v2/Dialog/Dialog"; +import { DialogBody } from "@probo/ui/src/v2/Dialog/DialogBody"; +import { DialogDescription } from "@probo/ui/src/v2/Dialog/DialogDescription"; +import { DialogFooter } from "@probo/ui/src/v2/Dialog/DialogFooter"; +import { DialogHeader } from "@probo/ui/src/v2/Dialog/DialogHeader"; +import { DialogPopup } from "@probo/ui/src/v2/Dialog/DialogPopup"; +import { DialogTitle } from "@probo/ui/src/v2/Dialog/DialogTitle"; +import { Field } from "@probo/ui/src/v2/form/Field"; +import { TextField } from "@probo/ui/src/v2/form/TextField"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useSubscribeToMailingList } from "#/lib/mailingList/useSubscribeToMailingList"; + +interface SubscribeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + // Relay store id of the current trust center (for the subscribe updater). + trustCenterId: string; + // Verified viewer email, shown read-only (server attributes the subscription). + viewerEmail: string; + organizationName: string; +} + +// Auth-gated mailing-list subscribe confirmation. The form only mounts while +// open so each open starts clean without a reset effect. +export function SubscribeDialog({ + open, + onOpenChange, + trustCenterId, + viewerEmail, + organizationName, +}: SubscribeDialogProps) { + return ( + + + {open && ( + onOpenChange(false)} + trustCenterId={trustCenterId} + viewerEmail={viewerEmail} + organizationName={organizationName} + /> + )} + + + ); +} + +interface SubscribeFormProps { + onClose: () => void; + trustCenterId: string; + viewerEmail: string; + organizationName: string; +} + +function SubscribeForm({ + onClose, + trustCenterId, + viewerEmail, + organizationName, +}: SubscribeFormProps) { + const { t } = useTranslation("updates"); + const [subscribe, isSubscribing] = useSubscribeToMailingList(trustCenterId); + const [submitted, setSubmitted] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (submitted) { + return; + } + try { + await subscribe(); + setSubmitted(true); + onClose(); + } catch { + // Errors are surfaced by the mutation notifier; keep the form open. + } + }; + + return ( +
{ void onSubmit(e); }}> + + {t("dialog.title")} + {t("dialog.description")} + + + +
+ + + + + {t("dialog.consent", { name: organizationName })} + +
+
+ + + + + +
+ ); +} diff --git a/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx b/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx index 171a3431d..4defd9528 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarUserMenu.tsx @@ -18,11 +18,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { CaretDownIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react"; +import { BellIcon, BellRingingIcon, CaretDownIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react"; import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar"; import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown"; import { DropdownGroup } from "@probo/ui/src/v2/Dropdown/DropdownGroup"; -import { DropdownGroupLabel } from "@probo/ui/src/v2/Dropdown/DropdownGroupLabel"; import { DropdownItem } from "@probo/ui/src/v2/Dropdown/DropdownItem"; import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup"; import { DropdownSeparator } from "@probo/ui/src/v2/Dropdown/DropdownSeparator"; @@ -31,6 +30,9 @@ import { Text } from "@probo/ui/src/v2/typography/Text"; import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; +import { useSignOut } from "#/lib/auth/useSignOut"; +import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext"; + import type { TopBarUserMenu_identity$key } from "./__generated__/TopBarUserMenu_identity.graphql"; import { topBarUserMenuTrigger } from "./variants"; @@ -48,10 +50,20 @@ interface TopBarUserMenuProps { export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) { const { t } = useTranslation(); const identity = useFragment(topBarUserMenuFragment, identityKey); + const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog(); + const [signOut, isSigningOut] = useSignOut(); // New users may not have set a full name yet; fall back to the email. const displayName = identity.fullName.trim() || identity.email; + const onSubscribeItemClick = () => { + if (isSubscribed) { + void unsubscribe(); + return; + } + openSubscribe(); + }; + return ( - {identity.email} +
+ + {displayName} + + + {identity.email} + +
- }> + : } + color={isSubscribed ? "success" : "accent"} + disabled={isUnsubscribing} + onClick={onSubscribeItemClick} + > + {isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")} + + + } + disabled={isSigningOut} + onClick={() => { void signOut(); }} + > {t("userMenu.signOut")}
diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index 9390fc2e8..c6aed80ae 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -33,6 +33,8 @@ export const REQUEST_FILE_PARAM = "request-file-id"; // Marker that re-opens the "New Request" dialog once the user lands back // authenticated (the data request form gates on sign-in before it opens). export const NEW_REQUEST_PARAM = "new-request"; +// Marker that re-opens the "Subscribe to updates" dialog after sign-in. +export const SUBSCRIBE_PARAM = "subscribe"; // Validates a `continue` target before we navigate to it. Only same-origin URLs // under the portal's path prefix are accepted; anything else falls back to the @@ -84,6 +86,14 @@ export function buildNewRequestContinueUrl(): string { return url.toString(); } +// Absolute URL of the current page with the subscribe marker set, so the +// mailing-list subscribe dialog re-opens after sign-in. +export function buildSubscribeContinueUrl(): string { + const url = new URL(window.location.href); + url.searchParams.set(SUBSCRIBE_PARAM, "true"); + return url.toString(); +} + // Maps a caught auth-gate error to the route that resolves it, carrying the // given `continueUrl` so the user returns here (and any deferred request // resumes) once the gate is cleared. Returns null for non-gate errors. Shared diff --git a/apps/compliance-portal/src/lib/auth/useSignOut.ts b/apps/compliance-portal/src/lib/auth/useSignOut.ts new file mode 100644 index 000000000..95a619c46 --- /dev/null +++ b/apps/compliance-portal/src/lib/auth/useSignOut.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql } from "relay-runtime"; + +import { useMutation } from "#/lib/relay/useMutation"; + +import type { useSignOutMutation } from "./__generated__/useSignOutMutation.graphql"; + +const signOutMutation = graphql` + mutation useSignOutMutation { + signOut { + success + } + } +`; + +// Closes the trust-center session and clears the session cookie. Callers reload +// the page after success so the UI drops back to the guest chrome. +export function useSignOut() { + const { t } = useTranslation(); + const [commit, isSigningOut] = useMutation(signOutMutation, { + errorToast: t("userMenu.signOutFailed"), + }); + + const signOut = useCallback(async () => { + await commit({ variables: {} }); + window.location.reload(); + }, [commit]); + + return [signOut, isSigningOut] as const; +} diff --git a/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx b/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx new file mode 100644 index 000000000..e3d11a9db --- /dev/null +++ b/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { graphql, useFragment } from "react-relay"; +import { useSearchParams } from "react-router"; + +import { SubscribeDialog } from "#/components/SubscribeDialog/SubscribeDialog"; +import { buildSubscribeContinueUrl, SUBSCRIBE_PARAM } from "#/lib/auth/continueUrl"; +import { useSignInDialog } from "#/lib/auth/signInDialogContext"; +import { + SubscribeDialogContextProvider, +} from "#/lib/mailingList/subscribeDialogContext"; +import { useUnsubscribeFromMailingList } from "#/lib/mailingList/useUnsubscribeFromMailingList"; + +import type { SubscribeDialogProvider_query$key } from "./__generated__/SubscribeDialogProvider_query.graphql"; + +export const subscribeDialogProviderFragment = graphql` + fragment SubscribeDialogProvider_query on Query { + viewer { + email + } + currentTrustCenter @required(action: THROW) { + id + organization { + name + } + viewerSubscription { + id + } + } + } +`; + +interface SubscribeDialogProviderProps { + queryKey: SubscribeDialogProvider_query$key; + children: ReactNode; +} + +// Owns the subscribe dialog and exposes openSubscribe / unsubscribe / isSubscribed +// to Updates CTAs and the TopBar user menu. Guests are sent through Sign-in with +// a continue URL that re-opens the dialog after authentication. +export function SubscribeDialogProvider({ + queryKey, + children, +}: SubscribeDialogProviderProps) { + const data = useFragment(subscribeDialogProviderFragment, queryKey); + const { openSignIn } = useSignInDialog(); + const [searchParams, setSearchParams] = useSearchParams(); + const [dialogOpen, setDialogOpen] = useState(false); + const [unsubscribeFromMailingList, isUnsubscribing] = useUnsubscribeFromMailingList(); + + const viewer = data.viewer; + const { id: trustCenterId, organization, viewerSubscription } = data.currentTrustCenter; + const isSubscribed = viewerSubscription != null; + + const openSubscribe = useCallback(() => { + if (viewer == null) { + openSignIn({ continueTo: buildSubscribeContinueUrl() }); + return; + } + setDialogOpen(true); + }, [openSignIn, viewer]); + + const unsubscribe = useCallback(async () => { + await unsubscribeFromMailingList({ variables: {} }); + }, [unsubscribeFromMailingList]); + + // After a guest signs in to subscribe, they land back with the subscribe + // marker; open the dialog once and drop the marker so a reload can't re-open. + const resumed = useRef(false); + useEffect(() => { + if (resumed.current || viewer == null || searchParams.get(SUBSCRIBE_PARAM) == null) { + return; + } + resumed.current = true; + setDialogOpen(true); + const next = new URLSearchParams(searchParams); + next.delete(SUBSCRIBE_PARAM); + setSearchParams(next, { replace: true }); + }, [viewer, searchParams, setSearchParams]); + + const value = useMemo( + () => ({ + openSubscribe, + isSubscribed, + unsubscribe, + isUnsubscribing, + }), + [openSubscribe, isSubscribed, unsubscribe, isUnsubscribing], + ); + + return ( + + {children} + {viewer != null && ( + + )} + + ); +} diff --git a/apps/compliance-portal/src/lib/mailingList/subscribeDialogContext.ts b/apps/compliance-portal/src/lib/mailingList/subscribeDialogContext.ts new file mode 100644 index 000000000..a144a932f --- /dev/null +++ b/apps/compliance-portal/src/lib/mailingList/subscribeDialogContext.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { createContext, useContext } from "react"; + +export type SubscribeDialogContextValue = { + // Opens the subscribe dialog, or the sign-in dialog when the viewer is a guest. + openSubscribe: () => void; + // True when the viewer already has a mailing-list subscription. + isSubscribed: boolean; + // Unsubscribes the viewer (used by the user-menu subscribed row). + unsubscribe: () => Promise; + isUnsubscribing: boolean; +}; + +const SubscribeDialogContext = createContext(null); + +export const SubscribeDialogContextProvider = SubscribeDialogContext.Provider; + +export function useSubscribeDialog(): SubscribeDialogContextValue { + const context = useContext(SubscribeDialogContext); + if (context === null) { + throw new Error("useSubscribeDialog must be used within a SubscribeDialogProvider"); + } + return context; +} diff --git a/apps/compliance-portal/src/lib/mailingList/useSubscribeToMailingList.ts b/apps/compliance-portal/src/lib/mailingList/useSubscribeToMailingList.ts new file mode 100644 index 000000000..c9e175151 --- /dev/null +++ b/apps/compliance-portal/src/lib/mailingList/useSubscribeToMailingList.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql } from "relay-runtime"; + +import { useMutation } from "#/lib/relay/useMutation"; + +import type { useSubscribeToMailingListMutation } from "./__generated__/useSubscribeToMailingListMutation.graphql"; + +const subscribeToMailingListMutation = graphql` + mutation useSubscribeToMailingListMutation { + subscribeToMailingList { + subscription { + id + } + } + } +`; + +// Subscribes the authenticated viewer to the trust center mailing list and +// links the new subscriber onto currentTrustCenter.viewerSubscription. +export function useSubscribeToMailingList(trustCenterId: string) { + const { t } = useTranslation("updates"); + const [commit, isSubscribing] = useMutation( + subscribeToMailingListMutation, + { successMessage: t("dialog.successToast") }, + ); + + const subscribe = useCallback(async () => { + await commit({ + variables: {}, + updater: (store, data) => { + const subscription = data?.subscribeToMailingList?.subscription; + if (!subscription?.id) { + return; + } + const trustCenterRecord = store.get(trustCenterId); + const subscriptionRecord = store.get(subscription.id); + if (trustCenterRecord == null || subscriptionRecord == null) { + return; + } + trustCenterRecord.setLinkedRecord(subscriptionRecord, "viewerSubscription"); + }, + }); + }, [commit, trustCenterId]); + + return [subscribe, isSubscribing] as const; +} diff --git a/apps/compliance-portal/src/lib/mailingList/useUnsubscribeFromMailingList.ts b/apps/compliance-portal/src/lib/mailingList/useUnsubscribeFromMailingList.ts new file mode 100644 index 000000000..ef4de11a3 --- /dev/null +++ b/apps/compliance-portal/src/lib/mailingList/useUnsubscribeFromMailingList.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 { useTranslation } from "react-i18next"; +import { graphql } from "relay-runtime"; + +import { useMutation } from "#/lib/relay/useMutation"; + +import type { useUnsubscribeFromMailingListMutation } from "./__generated__/useUnsubscribeFromMailingListMutation.graphql"; + +const unsubscribeFromMailingListMutation = graphql` + mutation useUnsubscribeFromMailingListMutation { + unsubscribeFromMailingList { + deletedMailingListSubscriberId @deleteRecord + } + } +`; + +// Removes the authenticated viewer from the trust center mailing list. The +// @deleteRecord directive clears viewerSubscription links in the store. +export function useUnsubscribeFromMailingList() { + const { t } = useTranslation("updates"); + return useMutation( + unsubscribeFromMailingListMutation, + { successMessage: t("dialog.unsubscribeToast") }, + ); +} diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx index 47206e37c..9ba2cf066 100644 --- a/apps/compliance-portal/src/pages/MainLayout.tsx +++ b/apps/compliance-portal/src/pages/MainLayout.tsx @@ -27,6 +27,7 @@ import { PoweredBy } from "#/components/PoweredBy/PoweredBy"; import { TopBar } from "#/components/TopBar/TopBar"; import { SignInDialogProvider } from "#/lib/auth/SignInDialogProvider"; import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest"; +import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider"; import type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql"; @@ -36,6 +37,7 @@ export const mainLayoutQuery = graphql` __typename } ...TopBar_query + ...SubscribeDialogProvider_query } `; @@ -55,13 +57,15 @@ export function MainLayout({ queryRef }: MainLayoutProps) { // page area scrolls on its own. Pages that fill the height (the document // viewer) then scroll their own body while their toolbar stays put. -
- -
- + +
+ +
+ +
+
- -
+ ); } diff --git a/apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx b/apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx index d80361d22..c8e83b03e 100644 --- a/apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx +++ b/apps/compliance-portal/src/pages/updates/_components/UpdatesSubscribeButton.tsx @@ -22,13 +22,26 @@ import { BellIcon } from "@phosphor-icons/react"; import { Button } from "@probo/ui/src/v2/Button/Button"; import { useTranslation } from "react-i18next"; -// "Subscribe to updates" call to action shared by the list header and the -// detail toolbar. Mailing-list subscription wiring is not implemented yet. +import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext"; + +// "Subscribe to updates" call to action shared by the list header, empty state, +// and detail toolbar. Hidden once the viewer is already subscribed. export function UpdatesSubscribeButton() { const { t } = useTranslation("updates"); + const { openSubscribe, isSubscribed } = useSubscribeDialog(); + + if (isSubscribed) { + return null; + } return ( - ); diff --git a/apps/compliance-portal/src/pages/updates/_locales/en-US.json b/apps/compliance-portal/src/pages/updates/_locales/en-US.json index 1f000e702..5e928c56e 100644 --- a/apps/compliance-portal/src/pages/updates/_locales/en-US.json +++ b/apps/compliance-portal/src/pages/updates/_locales/en-US.json @@ -5,5 +5,15 @@ "empty": { "title": "No updates yet.", "description": "Subscribe to get notified when new updates are published." + }, + "dialog": { + "title": "Subscribe to updates", + "description": "Get notified when we publish new compliance updates, add subprocessors, or renew certifications.", + "email": "Email Address", + "consent": "By subscribing, you agree to receive compliance updates from {{name}}. You can unsubscribe at any time.", + "cancel": "Cancel", + "submit": "Subscribe", + "successToast": "You will be notified when new updates are published.", + "unsubscribeToast": "You will no longer receive update notifications." } } diff --git a/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json b/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json index 4e12b08b0..4d454a4a7 100644 --- a/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json +++ b/apps/compliance-portal/src/pages/updates/_locales/fr-FR.json @@ -5,5 +5,15 @@ "empty": { "title": "Aucune mise à jour pour le moment.", "description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour." + }, + "dialog": { + "title": "S'abonner aux mises à jour", + "description": "Recevez une notification lorsque nous publions de nouvelles mises à jour de conformité, ajoutons des sous-traitants ou renouvelons des certifications.", + "email": "Adresse e-mail", + "consent": "En vous abonnant, vous acceptez de recevoir les mises à jour de conformité de {{name}}. Vous pouvez vous désabonner à tout moment.", + "cancel": "Annuler", + "submit": "S'abonner", + "successToast": "Vous serez informé lors de la publication de nouvelles mises à jour.", + "unsubscribeToast": "Vous ne recevrez plus les notifications de mises à jour." } } diff --git a/packages/ui/src/v2/Dropdown/DropdownItem.tsx b/packages/ui/src/v2/Dropdown/DropdownItem.tsx index 99aad0aec..0981e289c 100644 --- a/packages/ui/src/v2/Dropdown/DropdownItem.tsx +++ b/packages/ui/src/v2/Dropdown/DropdownItem.tsx @@ -28,14 +28,15 @@ export type DropdownItemProps = & Omit, "color" | "className"> & { className?: string; - color?: "accent" | "error"; + color?: "accent" | "error" | "success"; iconStart?: ReactNode; // Trailing keyboard shortcut hint (e.g. "⌘ E"). shortcut?: ReactNode; }; // A single actionable menu item. Inherits size/variant/highContrast from the -// popup; set `color="error"` for destructive actions. +// popup; set `color="error"` for destructive actions and `color="success"` for +// affirmative status rows (e.g. subscribed). export function DropdownItem(props: DropdownItemProps) { const { className, color = "accent", iconStart, shortcut, children, ...rest } = props; const { size, variant, highContrast } = useDropdownContext(); diff --git a/packages/ui/src/v2/Dropdown/variants.ts b/packages/ui/src/v2/Dropdown/variants.ts index 1c7af7f4a..b0ed19d63 100644 --- a/packages/ui/src/v2/Dropdown/variants.ts +++ b/packages/ui/src/v2/Dropdown/variants.ts @@ -49,6 +49,7 @@ export const dropdownItem = tv({ color: { accent: "text-sand-12", error: "text-red-11", + success: "text-green-11", }, highContrast: { true: "", @@ -63,6 +64,9 @@ export const dropdownItem = tv({ // error { variant: "solid", color: "error", class: "data-highlighted:bg-red-9 data-highlighted:text-white" }, { variant: "soft", color: "error", class: "data-highlighted:bg-red-4 data-highlighted:text-red-12" }, + // success + { variant: "solid", color: "success", class: "data-highlighted:bg-green-9 data-highlighted:text-white" }, + { variant: "soft", color: "success", class: "data-highlighted:bg-green-4 data-highlighted:text-green-12" }, ], defaultVariants: { size: 2, diff --git a/pkg/server/api/trust/v1/auth_resolvers.go b/pkg/server/api/trust/v1/auth_resolvers.go index 1ded8d77f..e0fdcbc6b 100644 --- a/pkg/server/api/trust/v1/auth_resolvers.go +++ b/pkg/server/api/trust/v1/auth_resolvers.go @@ -189,3 +189,24 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat return &types.UpdateFullNamePayload{Success: true}, nil } + +// SignOut is the resolver for the signOut field. +func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload, error) { + session := authn.SessionFromContext(ctx) + + err := r.iam.SessionService.CloseSession(ctx, session.ID) + if err != nil { + if _, ok := errors.AsType[*iam.ErrSessionNotFound](err); ok { + return &types.SignOutPayload{Success: true}, nil + } + + r.logger.ErrorCtx(ctx, "cannot close session", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + w := gqlutils.HTTPResponseWriterFromContext(ctx) + r.sessionCookie.Clear(w) + + return &types.SignOutPayload{Success: true}, nil +} diff --git a/pkg/server/api/trust/v1/graphql/auth.graphql b/pkg/server/api/trust/v1/graphql/auth.graphql index e4c0b694d..d1ee32af0 100644 --- a/pkg/server/api/trust/v1/graphql/auth.graphql +++ b/pkg/server/api/trust/v1/graphql/auth.graphql @@ -5,6 +5,7 @@ extend type Mutation { @authentication(required: OPTIONAL) updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload @authentication(required: PRESENT) @sessionOnly + signOut: SignOutPayload! @authentication(required: PRESENT) @sessionOnly } input SendMagicLinkInput { @@ -31,3 +32,7 @@ input UpdateFullNameInput { type UpdateFullNamePayload { success: Boolean! } + +type SignOutPayload { + success: Boolean! +}