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:
Émile Ré
2026-07-20 15:50:50 +02:00
parent 855a486790
commit e9542bc1a5
4 changed files with 68 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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