···44import { SubscribeWithHandle } from "./HandleSubscribe";
55import { EmailInput, EmailConfirm } from "./EmailSubscribe";
66import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess";
77+import { LinkIdentityModal } from "./LinkIdentityModal";
78import { Modal } from "components/Modal";
89import { ButtonPrimary } from "components/Buttons";
910import { ManageSubscription } from "./ManageSubscribe";
1011import { useToaster } from "components/Toast";
1212+import { useIdentityData } from "components/IdentityProvider";
1113import {
1214 requestPublicationEmailSubscription,
1315 confirmPublicationEmailSubscription,
···7375 let toaster = useToaster();
7476 let router = useRouter();
7577 const user = useViewerSubscription(props.publicationUri);
7878+ const { identity } = useIdentityData();
7679 let [email, setEmail] = useState(user.email ?? "");
7780 let [confirmState, setConfirmState] = useState<"confirm" | "success">(
7881 "confirm",
···8184 let [requesting, setRequesting] = useState(false);
8285 let [confirming, setConfirming] = useState(false);
8386 let [locallySubscribed, setLocallySubscribed] = useState(false);
8787+ let [linkModalOpen, setLinkModalOpen] = useState(false);
8888+ // Tracks that the user passed through LinkIdentityModal — when they enter
8989+ // the confirmation code we attach the email to their current atp identity
9090+ // (or merge from any existing email-only identity) instead of creating a
9191+ // disconnected email-only account.
9292+ let [linkToCurrent, setLinkToCurrent] = useState(false);
9393+9494+ const viewerHandle = identity?.bsky_profiles?.handle;
9595+ const viewerAtpDid = identity?.atp_did;
9696+ const viewerEmail = identity?.email;
9797+ // The atp-only-but-subscribing-via-email case: signed in as a Bluesky
9898+ // account with no email yet. The modal asks them to link the typed email
9999+ // (or log out) before we send a confirmation code.
100100+ const needsLinkConfirmation = !!viewerAtpDid && !viewerEmail && !!email;
101101+102102+ const sendRequest = async (link: boolean) => {
103103+ setRequesting(true);
104104+ setLinkToCurrent(link);
105105+ let res = await requestPublicationEmailSubscription(
106106+ props.publicationUri,
107107+ email,
108108+ );
109109+ setRequesting(false);
110110+ if (!res.ok) {
111111+ toaster({ type: "error", content: ERROR_MESSAGES[res.error] });
112112+ return;
113113+ }
114114+ if (res.value.confirmed) {
115115+ setConfirmState("success");
116116+ router.refresh();
117117+ }
118118+ setConfirmOpen(true);
119119+ };
8412085121 const isSubscribed = user.subscribed || locallySubscribed;
86122 return (
···106142 disabled={requesting || !email}
107143 onClick={async () => {
108144 if (requesting) return;
109109- setRequesting(true);
110110- let res = await requestPublicationEmailSubscription(
111111- props.publicationUri,
112112- email,
113113- );
114114- setRequesting(false);
115115- if (!res.ok) {
116116- toaster({
117117- type: "error",
118118- content: ERROR_MESSAGES[res.error],
119119- });
145145+ if (needsLinkConfirmation) {
146146+ setLinkModalOpen(true);
120147 return;
121148 }
122122- if (res.value.confirmed) {
123123- setConfirmState("success");
124124- router.refresh();
125125- }
126126- setConfirmOpen(true);
149149+ await sendRequest(false);
127150 }}
128151 >
129152 Subscribe
···138161 onSubscribed={() => setLocallySubscribed(true)}
139162 />
140163 )}
164164+ {props.newsletterMode && needsLinkConfirmation && (
165165+ <LinkIdentityModal
166166+ open={linkModalOpen}
167167+ onOpenChange={setLinkModalOpen}
168168+ signedInAs={viewerHandle ? `@${viewerHandle}` : "your Bluesky account"}
169169+ linkingIdentity={email}
170170+ confirmButtonLabel="Link email"
171171+ confirming={requesting}
172172+ onConfirm={async () => {
173173+ setLinkModalOpen(false);
174174+ await sendRequest(true);
175175+ }}
176176+ />
177177+ )}
141178 {props.newsletterMode && (
142179 <Modal
143180 open={confirmOpen}
···146183 if (!open) {
147184 if (confirmState === "success") setLocallySubscribed(true);
148185 setConfirmState("confirm");
186186+ setLinkToCurrent(false);
149187 }
150188 }}
151189 >
···164202 props.publicationUri,
165203 email,
166204 code,
205205+ linkToCurrent,
167206 );
168207 setConfirming(false);
169208 if (!res.ok) {
···192231 | "invalid_code"
193232 | "database_error"
194233 | "suppressed_spam_complaint"
195195- | "suppression_delete_failed";
234234+ | "suppression_delete_failed"
235235+ | "link_invalid_state"
236236+ | "email_belongs_to_other_account";
196237197238const ERROR_MESSAGES: Record<SubscribeError, string> = {
198239 invalid_email: "Please enter a valid email address.",
···205246 "This address was previously marked as spam and can't be resubscribed. Contact the publication to resolve.",
206247 suppression_delete_failed:
207248 "We couldn't clear a prior delivery issue on this address. Try again later.",
249249+ link_invalid_state:
250250+ "Couldn't link this email to your account. Try logging out and subscribing again.",
251251+ email_belongs_to_other_account:
252252+ "This email is already linked to a different Bluesky account. Log out to use that account instead.",
208253};
+54-9
app/api/oauth/[route]/route.ts
···1616 parseActionFromSearchParam,
1717} from "./afterSignInActions";
1818import { inngest } from "app/api/inngest/client";
1919+import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity";
19202021type OauthRequestClientState = {
2122 redirect: string | null;
2223 action: ActionAfterSignIn | null;
2324 link?: boolean;
2525+ // Auto-confirm a cross-identity merge instead of routing to /merge-accounts.
2626+ // Set when the caller already showed an in-context "link this account?"
2727+ // confirmation (e.g. the subscribe-flow LinkIdentityModal).
2828+ autoMerge?: boolean;
2429};
25302631export async function GET(
···3944 const handle = searchParams.get("handle") as string;
4045 const signup = searchParams.get("signup") === "true";
4146 const link = searchParams.get("link") === "true";
4747+ const autoMerge = searchParams.get("autoMerge") === "true";
4248 // Put originating page here!
4349 let redirect = searchParams.get("redirect_url");
4450 if (redirect) redirect = decodeURIComponent(redirect);
4551 let action = parseActionFromSearchParam(searchParams.get("action"));
4646- let state: OauthRequestClientState = { redirect, action, link };
5252+ let state: OauthRequestClientState = {
5353+ redirect,
5454+ action,
5555+ link,
5656+ autoMerge,
5757+ };
47584859 // Revoke any pending authentication requests if the connection is closed (optional)
4960 const ac = new AbortController();
···9010191102 // Explicit link flow from the LoginModal for an email-only user. Never
92103 // fall through to a normal DID login — we must either attach the atp_did
9393- // to the existing email identity or route to /merge-accounts.
104104+ // to the existing email identity or route to /merge-accounts (or merge
105105+ // inline if autoMerge is set).
94106 if (
95107 s.link &&
96108 currentIdentity &&
···105117 return handleAction(s.action, redirectPath);
106118 }
107119 if (identity.id !== currentIdentity.id) {
108108- await stagePendingMerge(identity.id, redirectPath);
109109- // Only reached if the token insert failed. Fall through to the
110110- // normal sign-in flow rather than blocking the user.
120120+ if (s.autoMerge) {
121121+ const merged = await mergeEmailIdentityIntoAtpIdentity({
122122+ sourceId: currentIdentity.id,
123123+ targetId: identity.id,
124124+ });
125125+ if (merged.ok) {
126126+ // Source identity is gone; clear it so the cross-identity
127127+ // merge block below doesn't try to merge a deleted row.
128128+ currentIdentity = null;
129129+ } else {
130130+ console.error(
131131+ "[oauth/callback] autoMerge failed:",
132132+ merged.error,
133133+ );
134134+ await stagePendingMerge(identity.id, redirectPath);
135135+ }
136136+ } else {
137137+ await stagePendingMerge(identity.id, redirectPath);
138138+ // Only reached if the token insert failed. Fall through to the
139139+ // normal sign-in flow rather than blocking the user.
140140+ }
111141 }
112142 // Same identity already linked — fall through to refresh session.
113143 }
···133163 currentIdentity.email
134164 ) {
135165 // DID already has an identity row. Caller is currently signed in as a
136136- // *different* email-only identity. Stage a pending merge and let the
137137- // user confirm on /merge-accounts before we touch either account.
138138- await stagePendingMerge(identity.id, redirectPath);
139139- // Only reached if the token insert failed — fall through.
166166+ // *different* email-only identity. Either merge inline (autoMerge
167167+ // means the caller already collected the user's confirmation) or
168168+ // stage a pending merge for /merge-accounts.
169169+ if (s.autoMerge) {
170170+ const merged = await mergeEmailIdentityIntoAtpIdentity({
171171+ sourceId: currentIdentity.id,
172172+ targetId: identity.id,
173173+ });
174174+ if (!merged.ok) {
175175+ console.error(
176176+ "[oauth/callback] autoMerge failed:",
177177+ merged.error,
178178+ );
179179+ await stagePendingMerge(identity.id, redirectPath);
180180+ }
181181+ } else {
182182+ await stagePendingMerge(identity.id, redirectPath);
183183+ // Only reached if the token insert failed — fall through.
184184+ }
140185 }
141186142187 // Trigger migration if identity needs it