Wire subscribe-to-updates in compliance portal
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é <emile@probo.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 { 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPopup>
|
||||
{open && (
|
||||
<SubscribeForm
|
||||
onClose={() => onOpenChange(false)}
|
||||
trustCenterId={trustCenterId}
|
||||
viewerEmail={viewerEmail}
|
||||
organizationName={organizationName}
|
||||
/>
|
||||
)}
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<form className="flex flex-col gap-4" onSubmit={(e) => { void onSubmit(e); }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dialog.title")}</DialogTitle>
|
||||
<DialogDescription>{t("dialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field label={t("dialog.email")}>
|
||||
<TextField value={viewerEmail} readOnly disabled />
|
||||
</Field>
|
||||
<Text size={1} color="faint">
|
||||
{t("dialog.consent", { name: organizationName })}
|
||||
</Text>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="soft" color="neutral" onClick={onClose}>
|
||||
{t("dialog.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="solid" color="neutral" highContrast loading={isSubscribing}>
|
||||
{t("dialog.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dropdown>
|
||||
<DropdownTrigger
|
||||
@@ -73,10 +85,31 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
||||
/>
|
||||
<DropdownPopup align="end">
|
||||
<DropdownGroup>
|
||||
<DropdownGroupLabel>{identity.email}</DropdownGroupLabel>
|
||||
<div className="flex w-full flex-col gap-1 px-3 py-3">
|
||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text size={1} color="faint" className="truncate">
|
||||
{identity.email}
|
||||
</Text>
|
||||
</div>
|
||||
</DropdownGroup>
|
||||
<DropdownSeparator />
|
||||
<DropdownItem color="error" iconStart={<SignOutIcon />}>
|
||||
<DropdownItem
|
||||
iconStart={isSubscribed ? <BellRingingIcon /> : <BellIcon />}
|
||||
color={isSubscribed ? "success" : "accent"}
|
||||
disabled={isUnsubscribing}
|
||||
onClick={onSubscribeItemClick}
|
||||
>
|
||||
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
||||
</DropdownItem>
|
||||
<DropdownSeparator />
|
||||
<DropdownItem
|
||||
color="error"
|
||||
iconStart={<SignOutIcon />}
|
||||
disabled={isSigningOut}
|
||||
onClick={() => { void signOut(); }}
|
||||
>
|
||||
{t("userMenu.signOut")}
|
||||
</DropdownItem>
|
||||
</DropdownPopup>
|
||||
|
||||
@@ -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
|
||||
|
||||
51
apps/compliance-portal/src/lib/auth/useSignOut.ts
Normal file
51
apps/compliance-portal/src/lib/auth/useSignOut.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 { 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<useSignOutMutation>(signOutMutation, {
|
||||
errorToast: t("userMenu.signOutFailed"),
|
||||
});
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await commit({ variables: {} });
|
||||
window.location.reload();
|
||||
}, [commit]);
|
||||
|
||||
return [signOut, isSigningOut] as const;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 {
|
||||
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 (
|
||||
<SubscribeDialogContextProvider value={value}>
|
||||
{children}
|
||||
{viewer != null && (
|
||||
<SubscribeDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
trustCenterId={trustCenterId}
|
||||
viewerEmail={viewer.email}
|
||||
organizationName={organization.name}
|
||||
/>
|
||||
)}
|
||||
</SubscribeDialogContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 { 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<void>;
|
||||
isUnsubscribing: boolean;
|
||||
};
|
||||
|
||||
const SubscribeDialogContext = createContext<SubscribeDialogContextValue | null>(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;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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 { 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<useSubscribeToMailingListMutation>(
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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 { 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<useUnsubscribeFromMailingListMutation>(
|
||||
unsubscribeFromMailingListMutation,
|
||||
{ successMessage: t("dialog.unsubscribeToast") },
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
<SignInDialogProvider>
|
||||
<div className="flex h-dvh flex-col bg-sand-2">
|
||||
<TopBar queryKey={data} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
<SubscribeDialogProvider queryKey={data}>
|
||||
<div className="flex h-dvh flex-col bg-sand-2">
|
||||
<TopBar queryKey={data} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
<PoweredBy label={t("footer.poweredBy")} />
|
||||
</div>
|
||||
<PoweredBy label={t("footer.poweredBy")} />
|
||||
</div>
|
||||
</SubscribeDialogProvider>
|
||||
</SignInDialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Button variant="soft" color="neutral" highContrast iconStart={<BellIcon />}>
|
||||
<Button
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<BellIcon />}
|
||||
onClick={openSubscribe}
|
||||
>
|
||||
{t("subscribe")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,15 @@ export type DropdownItemProps
|
||||
= & Omit<ComponentProps<typeof Menu.Item>, "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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user