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": {
|
"userMenu": {
|
||||||
"signOut": "Sign out"
|
"subscribe": "Subscribe to updates",
|
||||||
|
"subscribed": "Subscribed to updates",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"signOutFailed": "Cannot sign out"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"userMenu": {
|
"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": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// 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 { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
||||||
import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown";
|
import { Dropdown } from "@probo/ui/src/v2/Dropdown/Dropdown";
|
||||||
import { DropdownGroup } from "@probo/ui/src/v2/Dropdown/DropdownGroup";
|
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 { DropdownItem } from "@probo/ui/src/v2/Dropdown/DropdownItem";
|
||||||
import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup";
|
import { DropdownPopup } from "@probo/ui/src/v2/Dropdown/DropdownPopup";
|
||||||
import { DropdownSeparator } from "@probo/ui/src/v2/Dropdown/DropdownSeparator";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
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 type { TopBarUserMenu_identity$key } from "./__generated__/TopBarUserMenu_identity.graphql";
|
||||||
import { topBarUserMenuTrigger } from "./variants";
|
import { topBarUserMenuTrigger } from "./variants";
|
||||||
|
|
||||||
@@ -48,10 +50,20 @@ interface TopBarUserMenuProps {
|
|||||||
export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const identity = useFragment(topBarUserMenuFragment, identityKey);
|
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.
|
// New users may not have set a full name yet; fall back to the email.
|
||||||
const displayName = identity.fullName.trim() || identity.email;
|
const displayName = identity.fullName.trim() || identity.email;
|
||||||
|
|
||||||
|
const onSubscribeItemClick = () => {
|
||||||
|
if (isSubscribed) {
|
||||||
|
void unsubscribe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openSubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownTrigger
|
<DropdownTrigger
|
||||||
@@ -73,10 +85,31 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
|||||||
/>
|
/>
|
||||||
<DropdownPopup align="end">
|
<DropdownPopup align="end">
|
||||||
<DropdownGroup>
|
<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>
|
</DropdownGroup>
|
||||||
<DropdownSeparator />
|
<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")}
|
{t("userMenu.signOut")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownPopup>
|
</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
|
// 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).
|
// authenticated (the data request form gates on sign-in before it opens).
|
||||||
export const NEW_REQUEST_PARAM = "new-request";
|
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
|
// 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
|
// 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();
|
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
|
// 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
|
// given `continueUrl` so the user returns here (and any deferred request
|
||||||
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
// 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 { TopBar } from "#/components/TopBar/TopBar";
|
||||||
import { SignInDialogProvider } from "#/lib/auth/SignInDialogProvider";
|
import { SignInDialogProvider } from "#/lib/auth/SignInDialogProvider";
|
||||||
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
|
import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest";
|
||||||
|
import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider";
|
||||||
|
|
||||||
import type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql";
|
import type { MainLayoutQuery } from "./__generated__/MainLayoutQuery.graphql";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export const mainLayoutQuery = graphql`
|
|||||||
__typename
|
__typename
|
||||||
}
|
}
|
||||||
...TopBar_query
|
...TopBar_query
|
||||||
|
...SubscribeDialogProvider_query
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
|
|||||||
// page area scrolls on its own. Pages that fill the height (the document
|
// page area scrolls on its own. Pages that fill the height (the document
|
||||||
// viewer) then scroll their own body while their toolbar stays put.
|
// viewer) then scroll their own body while their toolbar stays put.
|
||||||
<SignInDialogProvider>
|
<SignInDialogProvider>
|
||||||
|
<SubscribeDialogProvider queryKey={data}>
|
||||||
<div className="flex h-dvh flex-col bg-sand-2">
|
<div className="flex h-dvh flex-col bg-sand-2">
|
||||||
<TopBar queryKey={data} />
|
<TopBar queryKey={data} />
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
@@ -62,6 +65,7 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
<PoweredBy label={t("footer.poweredBy")} />
|
<PoweredBy label={t("footer.poweredBy")} />
|
||||||
</div>
|
</div>
|
||||||
|
</SubscribeDialogProvider>
|
||||||
</SignInDialogProvider>
|
</SignInDialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,26 @@ import { BellIcon } from "@phosphor-icons/react";
|
|||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// "Subscribe to updates" call to action shared by the list header and the
|
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||||
// detail toolbar. Mailing-list subscription wiring is not implemented yet.
|
|
||||||
|
// "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() {
|
export function UpdatesSubscribeButton() {
|
||||||
const { t } = useTranslation("updates");
|
const { t } = useTranslation("updates");
|
||||||
|
const { openSubscribe, isSubscribed } = useSubscribeDialog();
|
||||||
|
|
||||||
|
if (isSubscribed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="soft" color="neutral" highContrast iconStart={<BellIcon />}>
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
iconStart={<BellIcon />}
|
||||||
|
onClick={openSubscribe}
|
||||||
|
>
|
||||||
{t("subscribe")}
|
{t("subscribe")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,5 +5,15 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"title": "No updates yet.",
|
"title": "No updates yet.",
|
||||||
"description": "Subscribe to get notified when new updates are published."
|
"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": {
|
"empty": {
|
||||||
"title": "Aucune mise à jour pour le moment.",
|
"title": "Aucune mise à jour pour le moment.",
|
||||||
"description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour."
|
"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">
|
= & Omit<ComponentProps<typeof Menu.Item>, "color" | "className">
|
||||||
& {
|
& {
|
||||||
className?: string;
|
className?: string;
|
||||||
color?: "accent" | "error";
|
color?: "accent" | "error" | "success";
|
||||||
iconStart?: ReactNode;
|
iconStart?: ReactNode;
|
||||||
// Trailing keyboard shortcut hint (e.g. "⌘ E").
|
// Trailing keyboard shortcut hint (e.g. "⌘ E").
|
||||||
shortcut?: ReactNode;
|
shortcut?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A single actionable menu item. Inherits size/variant/highContrast from the
|
// 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) {
|
export function DropdownItem(props: DropdownItemProps) {
|
||||||
const { className, color = "accent", iconStart, shortcut, children, ...rest } = props;
|
const { className, color = "accent", iconStart, shortcut, children, ...rest } = props;
|
||||||
const { size, variant, highContrast } = useDropdownContext();
|
const { size, variant, highContrast } = useDropdownContext();
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const dropdownItem = tv({
|
|||||||
color: {
|
color: {
|
||||||
accent: "text-sand-12",
|
accent: "text-sand-12",
|
||||||
error: "text-red-11",
|
error: "text-red-11",
|
||||||
|
success: "text-green-11",
|
||||||
},
|
},
|
||||||
highContrast: {
|
highContrast: {
|
||||||
true: "",
|
true: "",
|
||||||
@@ -63,6 +64,9 @@ export const dropdownItem = tv({
|
|||||||
// error
|
// error
|
||||||
{ variant: "solid", color: "error", class: "data-highlighted:bg-red-9 data-highlighted:text-white" },
|
{ 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" },
|
{ 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: {
|
defaultVariants: {
|
||||||
size: 2,
|
size: 2,
|
||||||
|
|||||||
@@ -189,3 +189,24 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
|
|||||||
|
|
||||||
return &types.UpdateFullNamePayload{Success: true}, nil
|
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)
|
@authentication(required: OPTIONAL)
|
||||||
updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload
|
updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload
|
||||||
@authentication(required: PRESENT) @sessionOnly
|
@authentication(required: PRESENT) @sessionOnly
|
||||||
|
signOut: SignOutPayload! @authentication(required: PRESENT) @sessionOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
input SendMagicLinkInput {
|
input SendMagicLinkInput {
|
||||||
@@ -31,3 +32,7 @@ input UpdateFullNameInput {
|
|||||||
type UpdateFullNamePayload {
|
type UpdateFullNamePayload {
|
||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignOutPayload {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user