Harden subscribe resume and sign-out paths
Address PR review: avoid reintroducing cleared URL markers, treat already-closed sessions as successful logout, and stop stale subscribe/sign-out completions from racing the UI. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -29,7 +29,7 @@ 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 { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSubscribeToMailingList } from "#/lib/mailingList/useSubscribeToMailingList";
|
||||
@@ -45,7 +45,8 @@ interface SubscribeDialogProps {
|
||||
}
|
||||
|
||||
// Auth-gated mailing-list subscribe confirmation. The form only mounts while
|
||||
// open so each open starts clean without a reset effect.
|
||||
// open so each open starts clean without a reset effect. Dismiss is blocked
|
||||
// while the mutation is in flight so a Cancel/Escape cannot race a reopen.
|
||||
export function SubscribeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -53,12 +54,23 @@ export function SubscribeDialog({
|
||||
viewerEmail,
|
||||
organizationName,
|
||||
}: SubscribeDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && isSubmitting) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogPopup>
|
||||
{open && (
|
||||
<SubscribeForm
|
||||
onClose={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsSubmitting}
|
||||
trustCenterId={trustCenterId}
|
||||
viewerEmail={viewerEmail}
|
||||
organizationName={organizationName}
|
||||
@@ -71,6 +83,7 @@ export function SubscribeDialog({
|
||||
|
||||
interface SubscribeFormProps {
|
||||
onClose: () => void;
|
||||
onSubmittingChange: (submitting: boolean) => void;
|
||||
trustCenterId: string;
|
||||
viewerEmail: string;
|
||||
organizationName: string;
|
||||
@@ -78,23 +91,39 @@ interface SubscribeFormProps {
|
||||
|
||||
function SubscribeForm({
|
||||
onClose,
|
||||
onSubmittingChange,
|
||||
trustCenterId,
|
||||
viewerEmail,
|
||||
organizationName,
|
||||
}: SubscribeFormProps) {
|
||||
const { t } = useTranslation("updates");
|
||||
const [subscribe, isSubscribing] = useSubscribeToMailingList(trustCenterId);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const aliveRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmittingChange(isSubscribing);
|
||||
return () => {
|
||||
onSubmittingChange(false);
|
||||
};
|
||||
}, [isSubscribing, onSubmittingChange]);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitted) {
|
||||
if (isSubscribing) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await subscribe();
|
||||
setSubmitted(true);
|
||||
onClose();
|
||||
if (aliveRef.current) {
|
||||
onClose();
|
||||
}
|
||||
} catch {
|
||||
// Errors are surfaced by the mutation notifier; keep the form open.
|
||||
}
|
||||
@@ -119,7 +148,13 @@ function SubscribeForm({
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="soft" color="neutral" onClick={onClose}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
disabled={isSubscribing}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("dialog.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="solid" color="neutral" highContrast loading={isSubscribing}>
|
||||
|
||||
@@ -43,8 +43,12 @@ export function useSignOut() {
|
||||
});
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await commit({ variables: {} });
|
||||
window.location.reload();
|
||||
try {
|
||||
await commit({ variables: {} });
|
||||
window.location.reload();
|
||||
} catch {
|
||||
// errorToast already handles user-facing feedback.
|
||||
}
|
||||
}, [commit]);
|
||||
|
||||
return [signOut, isSigningOut] as const;
|
||||
|
||||
@@ -87,11 +87,17 @@ export function SubscribeDialogProvider({
|
||||
}, [openSignIn, viewer]);
|
||||
|
||||
const unsubscribe = useCallback(async () => {
|
||||
await unsubscribeFromMailingList({ variables: {} });
|
||||
try {
|
||||
await unsubscribeFromMailingList({ variables: {} });
|
||||
} catch {
|
||||
// Errors are surfaced by the mutation notifier.
|
||||
}
|
||||
}, [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.
|
||||
// Derive the next params from the updater's previous snapshot so a concurrent
|
||||
// effect that already cleared an access-request marker is not undone.
|
||||
const resumed = useRef(false);
|
||||
useEffect(() => {
|
||||
if (resumed.current || viewer == null || searchParams.get(SUBSCRIBE_PARAM) == null) {
|
||||
@@ -99,9 +105,11 @@ export function SubscribeDialogProvider({
|
||||
}
|
||||
resumed.current = true;
|
||||
setDialogOpen(true);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(SUBSCRIBE_PARAM);
|
||||
setSearchParams(next, { replace: true });
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
next.delete(SUBSCRIBE_PARAM);
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [viewer, searchParams, setSearchParams]);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -196,13 +196,14 @@ func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload,
|
||||
|
||||
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
|
||||
_, notFound := errors.AsType[*iam.ErrSessionNotFound](err)
|
||||
_, expired := errors.AsType[*iam.ErrSessionExpired](err)
|
||||
if !notFound && !expired {
|
||||
r.logger.ErrorCtx(ctx, "cannot close session", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot close session", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
// Already closed or missing — still clear the cookie so the browser
|
||||
// drops the stale session on concurrent / retried logout.
|
||||
}
|
||||
|
||||
w := gqlutils.HTTPResponseWriterFromContext(ctx)
|
||||
|
||||
Reference in New Issue
Block a user