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:
Émile Ré
2026-07-20 15:23:44 +02:00
parent 8d95c1d59a
commit 855a486790
18 changed files with 601 additions and 17 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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") },
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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!
}