a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

add merge account flow if user subs with other method

Jared Pereira (Apr 28, 2026, 11:02 PM EDT) 0bc74a2e 9f740381

+460 -178
+9 -141
actions/mergeIdentity.ts
··· 1 1 "use server"; 2 2 3 3 import { cookies } from "next/headers"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 - import { eq, inArray, sql } from "drizzle-orm"; 6 - import { 7 - custom_domains, 8 - email_auth_tokens, 9 - identities, 10 - permission_token_on_homepage, 11 - publication_email_subscribers, 12 - user_entitlements, 13 - user_subscriptions, 14 - } from "drizzle/schema"; 15 - import { pool } from "supabase/pool"; 16 4 import { supabaseServerClient } from "supabase/serverClient"; 17 5 import { 18 6 AUTH_TOKEN_COOKIE, ··· 22 10 setAuthToken, 23 11 } from "src/auth"; 24 12 import { Err, Ok, type Result } from "src/result"; 25 - 26 - type MergeError = 27 - | "merge_not_pending" 28 - | "invalid_source" 29 - | "invalid_target" 30 - | "same_identity" 31 - | "database_error"; 13 + import { 14 + mergeEmailIdentityIntoAtpIdentity, 15 + type MergeError, 16 + } from "src/mergeIdentity"; 32 17 33 18 export async function confirmIdentityMerge(): Promise<Result<null, MergeError>> { 34 19 const jar = await cookies(); ··· 48 33 return Err("invalid_source"); 49 34 if (!target.identity.atp_did) return Err("invalid_target"); 50 35 51 - const sourceId = source.identity.id; 52 - const targetId = target.identity.id; 53 - const sourceEmail = source.identity.email; 54 - 55 - const client = await pool.connect(); 56 - try { 57 - const db = drizzle(client); 58 - await db.transaction(async (tx) => { 59 - // Re-verify invariants under a row lock. Protects against racing 60 - // concurrent merges or an identity being mutated between cookie 61 - // resolution and this transaction. 62 - const locked = await tx 63 - .select({ 64 - id: identities.id, 65 - email: identities.email, 66 - atp_did: identities.atp_did, 67 - }) 68 - .from(identities) 69 - .where(inArray(identities.id, [sourceId, targetId])) 70 - .for("update"); 71 - const lockedSource = locked.find((r) => r.id === sourceId); 72 - const lockedTarget = locked.find((r) => r.id === targetId); 73 - if (!lockedSource || !lockedTarget) 74 - throw new Error("merge: identity disappeared under lock"); 75 - if (lockedSource.atp_did !== null) 76 - throw new Error("merge: source has atp_did"); 77 - if (lockedTarget.atp_did === null) 78 - throw new Error("merge: target missing atp_did"); 79 - if (!lockedSource.email) throw new Error("merge: source missing email"); 80 - 81 - // email_auth_tokens: caller is about to swap auth_token to the pending 82 - // token (which already points at target). Source's tokens are now stale. 83 - await tx 84 - .delete(email_auth_tokens) 85 - .where(eq(email_auth_tokens.identity, sourceId)); 86 - 87 - // Target wins on (identity_id) PK collision. 88 - await tx.execute(sql` 89 - delete from user_subscriptions 90 - where identity_id = ${sourceId} 91 - and exists (select 1 from user_subscriptions where identity_id = ${targetId}) 92 - `); 93 - await tx 94 - .update(user_subscriptions) 95 - .set({ identity_id: targetId }) 96 - .where(eq(user_subscriptions.identity_id, sourceId)); 97 - 98 - // Target wins on (identity_id, entitlement_key) PK collision. 99 - await tx.execute(sql` 100 - delete from user_entitlements 101 - where identity_id = ${sourceId} 102 - and entitlement_key in ( 103 - select entitlement_key from user_entitlements where identity_id = ${targetId} 104 - ) 105 - `); 106 - await tx 107 - .update(user_entitlements) 108 - .set({ identity_id: targetId }) 109 - .where(eq(user_entitlements.identity_id, sourceId)); 110 - 111 - // Target wins on (token, identity) PK collision. 112 - await tx.execute(sql` 113 - delete from permission_token_on_homepage 114 - where identity = ${sourceId} 115 - and token in ( 116 - select token from permission_token_on_homepage where identity = ${targetId} 117 - ) 118 - `); 119 - await tx 120 - .update(permission_token_on_homepage) 121 - .set({ identity: targetId }) 122 - .where(eq(permission_token_on_homepage.identity, sourceId)); 123 - 124 - // Target wins on unique (publication, email) collision. 125 - await tx.execute(sql` 126 - delete from ${publication_email_subscribers} 127 - where ${publication_email_subscribers.identity_id} = ${sourceId} 128 - and (${publication_email_subscribers.publication}, ${publication_email_subscribers.email}) in ( 129 - select ${publication_email_subscribers.publication}, ${publication_email_subscribers.email} 130 - from ${publication_email_subscribers} 131 - where ${publication_email_subscribers.identity_id} = ${targetId} 132 - ) 133 - `); 134 - await tx 135 - .update(publication_email_subscribers) 136 - .set({ identity_id: targetId }) 137 - .where(eq(publication_email_subscribers.identity_id, sourceId)); 138 - 139 - await tx 140 - .update(custom_domains) 141 - .set({ identity_id: targetId }) 142 - .where(eq(custom_domains.identity_id, sourceId)); 143 - 144 - // identities.email is unique — step via NULL so we don't collide 145 - // mid-swap. custom_domains.identity is nullable and cascades on update; 146 - // we re-set it explicitly after the final value lands because NULL→value 147 - // cascades don't fire. 148 - await tx 149 - .update(identities) 150 - .set({ email: null }) 151 - .where(eq(identities.id, targetId)); 152 - await tx 153 - .update(identities) 154 - .set({ email: null }) 155 - .where(eq(identities.id, sourceId)); 156 - await tx 157 - .update(identities) 158 - .set({ email: sourceEmail }) 159 - .where(eq(identities.id, targetId)); 160 - await tx 161 - .update(custom_domains) 162 - .set({ identity: sourceEmail }) 163 - .where(eq(custom_domains.identity_id, targetId)); 164 - 165 - await tx.delete(identities).where(eq(identities.id, sourceId)); 166 - }); 167 - } catch (e) { 168 - console.error("[mergeIdentity] transaction failed:", e); 169 - return Err("database_error"); 170 - } finally { 171 - client.release(); 172 - } 36 + const result = await mergeEmailIdentityIntoAtpIdentity({ 37 + sourceId: source.identity.id, 38 + targetId: target.identity.id, 39 + }); 40 + if (!result.ok) return result; 173 41 174 42 await setAuthToken(pendingTokenId!); 175 43 await removePendingMergeToken();
+156
src/mergeIdentity.ts
··· 1 + import { drizzle } from "drizzle-orm/node-postgres"; 2 + import { eq, inArray, sql } from "drizzle-orm"; 3 + import { 4 + custom_domains, 5 + email_auth_tokens, 6 + identities, 7 + permission_token_on_homepage, 8 + publication_email_subscribers, 9 + user_entitlements, 10 + user_subscriptions, 11 + } from "drizzle/schema"; 12 + import { pool } from "supabase/pool"; 13 + import { Err, Ok, type Result } from "src/result"; 14 + 15 + export type MergeError = 16 + | "merge_not_pending" 17 + | "invalid_source" 18 + | "invalid_target" 19 + | "same_identity" 20 + | "database_error"; 21 + 22 + // Merges an email-only identity (source) into an atp-linked identity (target). 23 + // Re-verifies invariants under a row lock so concurrent mutations can't 24 + // invalidate them mid-transaction. 25 + // 26 + // NOT a server action — kept in a plain module so it can't be invoked over 27 + // the wire. Callers must validate that the requester is authorized to merge 28 + // these specific identities before calling. 29 + export async function mergeEmailIdentityIntoAtpIdentity(args: { 30 + sourceId: string; 31 + targetId: string; 32 + }): Promise<Result<null, MergeError>> { 33 + const { sourceId, targetId } = args; 34 + if (sourceId === targetId) return Err("same_identity"); 35 + 36 + const client = await pool.connect(); 37 + try { 38 + const db = drizzle(client); 39 + await db.transaction(async (tx) => { 40 + const locked = await tx 41 + .select({ 42 + id: identities.id, 43 + email: identities.email, 44 + atp_did: identities.atp_did, 45 + }) 46 + .from(identities) 47 + .where(inArray(identities.id, [sourceId, targetId])) 48 + .for("update"); 49 + const lockedSource = locked.find((r) => r.id === sourceId); 50 + const lockedTarget = locked.find((r) => r.id === targetId); 51 + if (!lockedSource || !lockedTarget) 52 + throw new Error("merge: identity disappeared under lock"); 53 + if (lockedSource.atp_did !== null) 54 + throw new Error("merge: source has atp_did"); 55 + if (lockedTarget.atp_did === null) 56 + throw new Error("merge: target missing atp_did"); 57 + if (!lockedSource.email) throw new Error("merge: source missing email"); 58 + 59 + const sourceEmail = lockedSource.email; 60 + 61 + // email_auth_tokens: source's tokens are stale once source is gone. 62 + // Caller is responsible for swapping any cookie-held auth_token to a 63 + // target-pointing token before calling. 64 + await tx 65 + .delete(email_auth_tokens) 66 + .where(eq(email_auth_tokens.identity, sourceId)); 67 + 68 + // Target wins on (identity_id) PK collision. 69 + await tx.execute(sql` 70 + delete from user_subscriptions 71 + where identity_id = ${sourceId} 72 + and exists (select 1 from user_subscriptions where identity_id = ${targetId}) 73 + `); 74 + await tx 75 + .update(user_subscriptions) 76 + .set({ identity_id: targetId }) 77 + .where(eq(user_subscriptions.identity_id, sourceId)); 78 + 79 + // Target wins on (identity_id, entitlement_key) PK collision. 80 + await tx.execute(sql` 81 + delete from user_entitlements 82 + where identity_id = ${sourceId} 83 + and entitlement_key in ( 84 + select entitlement_key from user_entitlements where identity_id = ${targetId} 85 + ) 86 + `); 87 + await tx 88 + .update(user_entitlements) 89 + .set({ identity_id: targetId }) 90 + .where(eq(user_entitlements.identity_id, sourceId)); 91 + 92 + // Target wins on (token, identity) PK collision. 93 + await tx.execute(sql` 94 + delete from permission_token_on_homepage 95 + where identity = ${sourceId} 96 + and token in ( 97 + select token from permission_token_on_homepage where identity = ${targetId} 98 + ) 99 + `); 100 + await tx 101 + .update(permission_token_on_homepage) 102 + .set({ identity: targetId }) 103 + .where(eq(permission_token_on_homepage.identity, sourceId)); 104 + 105 + // Target wins on unique (publication, email) collision. 106 + await tx.execute(sql` 107 + delete from ${publication_email_subscribers} 108 + where ${publication_email_subscribers.identity_id} = ${sourceId} 109 + and (${publication_email_subscribers.publication}, ${publication_email_subscribers.email}) in ( 110 + select ${publication_email_subscribers.publication}, ${publication_email_subscribers.email} 111 + from ${publication_email_subscribers} 112 + where ${publication_email_subscribers.identity_id} = ${targetId} 113 + ) 114 + `); 115 + await tx 116 + .update(publication_email_subscribers) 117 + .set({ identity_id: targetId }) 118 + .where(eq(publication_email_subscribers.identity_id, sourceId)); 119 + 120 + await tx 121 + .update(custom_domains) 122 + .set({ identity_id: targetId }) 123 + .where(eq(custom_domains.identity_id, sourceId)); 124 + 125 + // identities.email is unique — step via NULL so we don't collide 126 + // mid-swap. custom_domains.identity is nullable and cascades on update; 127 + // we re-set it explicitly after the final value lands because NULL→value 128 + // cascades don't fire. 129 + await tx 130 + .update(identities) 131 + .set({ email: null }) 132 + .where(eq(identities.id, targetId)); 133 + await tx 134 + .update(identities) 135 + .set({ email: null }) 136 + .where(eq(identities.id, sourceId)); 137 + await tx 138 + .update(identities) 139 + .set({ email: sourceEmail }) 140 + .where(eq(identities.id, targetId)); 141 + await tx 142 + .update(custom_domains) 143 + .set({ identity: sourceEmail }) 144 + .where(eq(custom_domains.identity_id, targetId)); 145 + 146 + await tx.delete(identities).where(eq(identities.id, sourceId)); 147 + }); 148 + } catch (e) { 149 + console.error("[mergeIdentity] transaction failed:", e); 150 + return Err("database_error"); 151 + } finally { 152 + client.release(); 153 + } 154 + 155 + return Ok(null); 156 + }
+67 -3
actions/publications/subscribeEmail.tsx
··· 1 1 "use server"; 2 2 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 + import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; 4 5 import { supabaseServerClient } from "supabase/serverClient"; 5 6 import { setAuthToken } from "src/auth"; 6 7 import { PubConfirmEmail } from "emails/pubConfirmEmail"; ··· 29 30 | "suppression_delete_failed" 30 31 | ConfirmationError; 31 32 type RequestSuccess = { confirmed: boolean }; 32 - type ConfirmError = "subscriber_not_found" | ConfirmationError; 33 + type ConfirmError = 34 + | "subscriber_not_found" 35 + | "link_invalid_state" 36 + | "email_belongs_to_other_account" 37 + | ConfirmationError; 33 38 type UnsubscribeError = "unauthorized" | "not_subscribed" | "database_error"; 34 39 35 40 export async function requestPublicationEmailSubscription( ··· 174 179 publicationUri: string, 175 180 emailRaw: string, 176 181 code: string, 182 + linkToCurrent: boolean = false, 177 183 ): Promise<Result<null, ConfirmError>> { 178 184 const email = emailRaw.trim().toLowerCase(); 179 185 ··· 192 198 // The confirmation code proves ownership of `email`. Issue (or look up) an 193 199 // auth token for it so the subscriber is logged in and can one-click 194 200 // subscribe to other publications from the same device. 195 - const identityId = await ensureAuthTokenForEmail(email); 196 - if (!identityId) return Err("database_error"); 201 + let identityId: string | null; 202 + if (linkToCurrent) { 203 + const linkResult = await linkEmailToCurrentIdentity(email); 204 + if (!linkResult.ok) return linkResult; 205 + identityId = linkResult.value; 206 + } else { 207 + identityId = await ensureAuthTokenForEmail(email); 208 + if (!identityId) return Err("database_error"); 209 + } 197 210 198 211 const [{ error: updateError }, { error: eventError }] = await Promise.all([ 199 212 supabaseServerClient ··· 310 323 // stream. Phase 7 handles webhook-driven suppression reconciliation. 311 324 312 325 return Ok(null); 326 + } 327 + 328 + // Confirms an email subscription where the user has already chosen to link 329 + // the email to their currently signed-in atp-only identity (via the 330 + // LinkIdentityModal in the subscribe flow). Either attaches `email` to the 331 + // current identity, or — if another email-only identity already owns it — 332 + // merges that identity into the current one. 333 + async function linkEmailToCurrentIdentity( 334 + email: string, 335 + ): Promise<Result<string, ConfirmError>> { 336 + const [current, { data: existing }] = await Promise.all([ 337 + getIdentityData(), 338 + supabaseServerClient 339 + .from("identities") 340 + .select("id, atp_did") 341 + .eq("email", email) 342 + .maybeSingle(), 343 + ]); 344 + if (!current || !current.atp_did) return Err("link_invalid_state"); 345 + 346 + // Already linked — confirmation is a no-op for the identity row itself. 347 + if (current.email && current.email.toLowerCase() === email) 348 + return Ok(current.id); 349 + 350 + // Current identity already has a *different* email. Linking would clobber 351 + // it, which the modal didn't promise — refuse. 352 + if (current.email) return Err("link_invalid_state"); 353 + 354 + if (!existing || existing.id === current.id) { 355 + const { error } = await supabaseServerClient 356 + .from("identities") 357 + .update({ email }) 358 + .eq("id", current.id); 359 + if (error) { 360 + console.error("[subscribeEmail] attach email failed:", error); 361 + return Err("database_error"); 362 + } 363 + return Ok(current.id); 364 + } 365 + 366 + // Existing identity owns this email and isn't us. We can only merge if it's 367 + // an unlinked email-only account; otherwise it has its own atp_did and 368 + // merging would silently drop one of the two Bluesky links. 369 + if (existing.atp_did) return Err("email_belongs_to_other_account"); 370 + 371 + const merged = await mergeEmailIdentityIntoAtpIdentity({ 372 + sourceId: existing.id, 373 + targetId: current.id, 374 + }); 375 + if (!merged.ok) return Err("database_error"); 376 + return Ok(current.id); 313 377 } 314 378 315 379 async function ensureAuthTokenForEmail(email: string): Promise<string | null> {
+43 -8
components/Subscribe/HandleSubscribe.tsx
··· 13 13 import { Avatar } from "components/Avatar"; 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { useRecordFromDid } from "src/utils/useRecordFromDid"; 16 + import { LinkIdentityModal } from "./LinkIdentityModal"; 16 17 const apps = [ 17 18 { name: "Leaflet", logo: "/logos/leaflet.svg" }, 18 19 { name: "Bluesky", logo: "/logos/bluesky.svg" }, ··· 54 55 let [loading, setLoading] = useState(false); 55 56 let [subscribing, setSubscribing] = useState(false); 56 57 let [oauthError, setOauthError] = useState<OAuthSessionError | null>(null); 58 + // When an email-only user subscribes via the atproto flow, we surface a 59 + // confirmation modal first ("link Bluesky to your account?") so they can't 60 + // accidentally orphan their email account. 61 + let [pendingLinkHandle, setPendingLinkHandle] = useState<string | null>(null); 62 + const viewerEmail = identity?.email; 63 + const viewerAtpDid = identity?.atp_did; 64 + const needsLinkConfirmation = 65 + !!viewerEmail && !viewerAtpDid; 66 + 67 + const redirectToOauthForSubscribe = (handle: string, link: boolean) => { 68 + let action = encodeActionToSearchParam({ 69 + action: "subscribe", 70 + publication: props.publicationUri, 71 + }); 72 + let url = new URL(window.location.href); 73 + url.searchParams.set("refreshAuth", ""); 74 + let redirectUrl = encodeURIComponent(url.toString()); 75 + let extra = link ? "&link=true&autoMerge=true" : ""; 76 + window.location.href = `/api/oauth/login?handle=${encodeURIComponent(handle)}&redirect_url=${redirectUrl}&action=${action}${extra}`; 77 + }; 57 78 58 79 if (props.user.loggedIn && props.user.handle) { 59 80 return ( ··· 119 140 onSubmit={(handle) => { 120 141 let trimmed = handle.trim(); 121 142 if (!trimmed) return; 143 + if (needsLinkConfirmation) { 144 + setPendingLinkHandle(trimmed); 145 + return; 146 + } 122 147 setLoading(true); 123 - let action = encodeActionToSearchParam({ 124 - action: "subscribe", 125 - publication: props.publicationUri, 126 - }); 127 - let url = new URL(window.location.href); 128 - url.searchParams.set("refreshAuth", ""); 129 - let redirectUrl = encodeURIComponent(url.toString()); 130 - window.location.href = `/api/oauth/login?handle=${encodeURIComponent(trimmed)}&redirect_url=${redirectUrl}&action=${action}`; 148 + redirectToOauthForSubscribe(trimmed, false); 131 149 }} 132 150 action=<div className="bg-accent-1 rounded-md px-1 text-accent-2 font-bold text-sm"> 133 151 Subscribe ··· 136 154 <div className=" pt-1 "> 137 155 <AtmosphericHandleInfo /> 138 156 </div> 157 + {needsLinkConfirmation && ( 158 + <LinkIdentityModal 159 + open={pendingLinkHandle !== null} 160 + onOpenChange={(open) => { 161 + if (!open) setPendingLinkHandle(null); 162 + }} 163 + signedInAs={viewerEmail!} 164 + linkingIdentity={`@${pendingLinkHandle ?? ""}`} 165 + confirmButtonLabel="Link Bluesky" 166 + confirming={loading} 167 + onConfirm={() => { 168 + if (!pendingLinkHandle) return; 169 + setLoading(true); 170 + redirectToOauthForSubscribe(pendingLinkHandle, true); 171 + }} 172 + /> 173 + )} 139 174 </div> 140 175 ); 141 176 };
+69
components/Subscribe/LinkIdentityModal.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { useRouter } from "next/navigation"; 4 + import { mutate } from "swr"; 5 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 6 + import { Modal } from "components/Modal"; 7 + import { DotLoader } from "components/utils/DotLoader"; 8 + 9 + export const LinkIdentityModal = (props: { 10 + open: boolean; 11 + onOpenChange: (open: boolean) => void; 12 + signedInAs: string; 13 + linkingIdentity: string; 14 + confirmButtonLabel: string; 15 + onConfirm: () => void | Promise<void>; 16 + confirming?: boolean; 17 + }) => { 18 + let router = useRouter(); 19 + let [loggingOut, setLoggingOut] = useState(false); 20 + 21 + return ( 22 + <Modal 23 + open={props.open} 24 + onOpenChange={(o) => { 25 + if (loggingOut || props.confirming) return; 26 + props.onOpenChange(o); 27 + }} 28 + className="w-[400px] max-w-full" 29 + > 30 + <div className="flex flex-col gap-3"> 31 + <h3 className="text-primary leading-tight"> 32 + You're signed in as{" "} 33 + <span className="font-bold">{props.signedInAs}</span> 34 + </h3> 35 + <p className="text-secondary leading-snug"> 36 + Do you want to link{" "} 37 + <span className="font-bold">{props.linkingIdentity}</span> to this 38 + account? 39 + </p> 40 + <div className="flex flex-wrap gap-2 justify-end pt-1"> 41 + <ButtonSecondary 42 + disabled={loggingOut || props.confirming} 43 + onClick={async () => { 44 + if (loggingOut) return; 45 + setLoggingOut(true); 46 + try { 47 + await fetch("/api/auth/logout"); 48 + } finally { 49 + mutate("identity", null); 50 + router.refresh(); 51 + } 52 + }} 53 + > 54 + {loggingOut ? <DotLoader /> : "Log out"} 55 + </ButtonSecondary> 56 + <ButtonPrimary 57 + disabled={loggingOut || props.confirming} 58 + onClick={() => { 59 + if (props.confirming) return; 60 + void props.onConfirm(); 61 + }} 62 + > 63 + {props.confirming ? <DotLoader /> : props.confirmButtonLabel} 64 + </ButtonPrimary> 65 + </div> 66 + </div> 67 + </Modal> 68 + ); 69 + };
+62 -17
components/Subscribe/SubscribeButton.tsx
··· 4 4 import { SubscribeWithHandle } from "./HandleSubscribe"; 5 5 import { EmailInput, EmailConfirm } from "./EmailSubscribe"; 6 6 import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess"; 7 + import { LinkIdentityModal } from "./LinkIdentityModal"; 7 8 import { Modal } from "components/Modal"; 8 9 import { ButtonPrimary } from "components/Buttons"; 9 10 import { ManageSubscription } from "./ManageSubscribe"; 10 11 import { useToaster } from "components/Toast"; 12 + import { useIdentityData } from "components/IdentityProvider"; 11 13 import { 12 14 requestPublicationEmailSubscription, 13 15 confirmPublicationEmailSubscription, ··· 73 75 let toaster = useToaster(); 74 76 let router = useRouter(); 75 77 const user = useViewerSubscription(props.publicationUri); 78 + const { identity } = useIdentityData(); 76 79 let [email, setEmail] = useState(user.email ?? ""); 77 80 let [confirmState, setConfirmState] = useState<"confirm" | "success">( 78 81 "confirm", ··· 81 84 let [requesting, setRequesting] = useState(false); 82 85 let [confirming, setConfirming] = useState(false); 83 86 let [locallySubscribed, setLocallySubscribed] = useState(false); 87 + let [linkModalOpen, setLinkModalOpen] = useState(false); 88 + // Tracks that the user passed through LinkIdentityModal — when they enter 89 + // the confirmation code we attach the email to their current atp identity 90 + // (or merge from any existing email-only identity) instead of creating a 91 + // disconnected email-only account. 92 + let [linkToCurrent, setLinkToCurrent] = useState(false); 93 + 94 + const viewerHandle = identity?.bsky_profiles?.handle; 95 + const viewerAtpDid = identity?.atp_did; 96 + const viewerEmail = identity?.email; 97 + // The atp-only-but-subscribing-via-email case: signed in as a Bluesky 98 + // account with no email yet. The modal asks them to link the typed email 99 + // (or log out) before we send a confirmation code. 100 + const needsLinkConfirmation = !!viewerAtpDid && !viewerEmail && !!email; 101 + 102 + const sendRequest = async (link: boolean) => { 103 + setRequesting(true); 104 + setLinkToCurrent(link); 105 + let res = await requestPublicationEmailSubscription( 106 + props.publicationUri, 107 + email, 108 + ); 109 + setRequesting(false); 110 + if (!res.ok) { 111 + toaster({ type: "error", content: ERROR_MESSAGES[res.error] }); 112 + return; 113 + } 114 + if (res.value.confirmed) { 115 + setConfirmState("success"); 116 + router.refresh(); 117 + } 118 + setConfirmOpen(true); 119 + }; 84 120 85 121 const isSubscribed = user.subscribed || locallySubscribed; 86 122 return ( ··· 106 142 disabled={requesting || !email} 107 143 onClick={async () => { 108 144 if (requesting) return; 109 - setRequesting(true); 110 - let res = await requestPublicationEmailSubscription( 111 - props.publicationUri, 112 - email, 113 - ); 114 - setRequesting(false); 115 - if (!res.ok) { 116 - toaster({ 117 - type: "error", 118 - content: ERROR_MESSAGES[res.error], 119 - }); 145 + if (needsLinkConfirmation) { 146 + setLinkModalOpen(true); 120 147 return; 121 148 } 122 - if (res.value.confirmed) { 123 - setConfirmState("success"); 124 - router.refresh(); 125 - } 126 - setConfirmOpen(true); 149 + await sendRequest(false); 127 150 }} 128 151 > 129 152 Subscribe ··· 138 161 onSubscribed={() => setLocallySubscribed(true)} 139 162 /> 140 163 )} 164 + {props.newsletterMode && needsLinkConfirmation && ( 165 + <LinkIdentityModal 166 + open={linkModalOpen} 167 + onOpenChange={setLinkModalOpen} 168 + signedInAs={viewerHandle ? `@${viewerHandle}` : "your Bluesky account"} 169 + linkingIdentity={email} 170 + confirmButtonLabel="Link email" 171 + confirming={requesting} 172 + onConfirm={async () => { 173 + setLinkModalOpen(false); 174 + await sendRequest(true); 175 + }} 176 + /> 177 + )} 141 178 {props.newsletterMode && ( 142 179 <Modal 143 180 open={confirmOpen} ··· 146 183 if (!open) { 147 184 if (confirmState === "success") setLocallySubscribed(true); 148 185 setConfirmState("confirm"); 186 + setLinkToCurrent(false); 149 187 } 150 188 }} 151 189 > ··· 164 202 props.publicationUri, 165 203 email, 166 204 code, 205 + linkToCurrent, 167 206 ); 168 207 setConfirming(false); 169 208 if (!res.ok) { ··· 192 231 | "invalid_code" 193 232 | "database_error" 194 233 | "suppressed_spam_complaint" 195 - | "suppression_delete_failed"; 234 + | "suppression_delete_failed" 235 + | "link_invalid_state" 236 + | "email_belongs_to_other_account"; 196 237 197 238 const ERROR_MESSAGES: Record<SubscribeError, string> = { 198 239 invalid_email: "Please enter a valid email address.", ··· 205 246 "This address was previously marked as spam and can't be resubscribed. Contact the publication to resolve.", 206 247 suppression_delete_failed: 207 248 "We couldn't clear a prior delivery issue on this address. Try again later.", 249 + link_invalid_state: 250 + "Couldn't link this email to your account. Try logging out and subscribing again.", 251 + email_belongs_to_other_account: 252 + "This email is already linked to a different Bluesky account. Log out to use that account instead.", 208 253 };
+54 -9
app/api/oauth/[route]/route.ts
··· 16 16 parseActionFromSearchParam, 17 17 } from "./afterSignInActions"; 18 18 import { inngest } from "app/api/inngest/client"; 19 + import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; 19 20 20 21 type OauthRequestClientState = { 21 22 redirect: string | null; 22 23 action: ActionAfterSignIn | null; 23 24 link?: boolean; 25 + // Auto-confirm a cross-identity merge instead of routing to /merge-accounts. 26 + // Set when the caller already showed an in-context "link this account?" 27 + // confirmation (e.g. the subscribe-flow LinkIdentityModal). 28 + autoMerge?: boolean; 24 29 }; 25 30 26 31 export async function GET( ··· 39 44 const handle = searchParams.get("handle") as string; 40 45 const signup = searchParams.get("signup") === "true"; 41 46 const link = searchParams.get("link") === "true"; 47 + const autoMerge = searchParams.get("autoMerge") === "true"; 42 48 // Put originating page here! 43 49 let redirect = searchParams.get("redirect_url"); 44 50 if (redirect) redirect = decodeURIComponent(redirect); 45 51 let action = parseActionFromSearchParam(searchParams.get("action")); 46 - let state: OauthRequestClientState = { redirect, action, link }; 52 + let state: OauthRequestClientState = { 53 + redirect, 54 + action, 55 + link, 56 + autoMerge, 57 + }; 47 58 48 59 // Revoke any pending authentication requests if the connection is closed (optional) 49 60 const ac = new AbortController(); ··· 90 101 91 102 // Explicit link flow from the LoginModal for an email-only user. Never 92 103 // fall through to a normal DID login — we must either attach the atp_did 93 - // to the existing email identity or route to /merge-accounts. 104 + // to the existing email identity or route to /merge-accounts (or merge 105 + // inline if autoMerge is set). 94 106 if ( 95 107 s.link && 96 108 currentIdentity && ··· 105 117 return handleAction(s.action, redirectPath); 106 118 } 107 119 if (identity.id !== currentIdentity.id) { 108 - await stagePendingMerge(identity.id, redirectPath); 109 - // Only reached if the token insert failed. Fall through to the 110 - // normal sign-in flow rather than blocking the user. 120 + if (s.autoMerge) { 121 + const merged = await mergeEmailIdentityIntoAtpIdentity({ 122 + sourceId: currentIdentity.id, 123 + targetId: identity.id, 124 + }); 125 + if (merged.ok) { 126 + // Source identity is gone; clear it so the cross-identity 127 + // merge block below doesn't try to merge a deleted row. 128 + currentIdentity = null; 129 + } else { 130 + console.error( 131 + "[oauth/callback] autoMerge failed:", 132 + merged.error, 133 + ); 134 + await stagePendingMerge(identity.id, redirectPath); 135 + } 136 + } else { 137 + await stagePendingMerge(identity.id, redirectPath); 138 + // Only reached if the token insert failed. Fall through to the 139 + // normal sign-in flow rather than blocking the user. 140 + } 111 141 } 112 142 // Same identity already linked — fall through to refresh session. 113 143 } ··· 133 163 currentIdentity.email 134 164 ) { 135 165 // DID already has an identity row. Caller is currently signed in as a 136 - // *different* email-only identity. Stage a pending merge and let the 137 - // user confirm on /merge-accounts before we touch either account. 138 - await stagePendingMerge(identity.id, redirectPath); 139 - // Only reached if the token insert failed — fall through. 166 + // *different* email-only identity. Either merge inline (autoMerge 167 + // means the caller already collected the user's confirmation) or 168 + // stage a pending merge for /merge-accounts. 169 + if (s.autoMerge) { 170 + const merged = await mergeEmailIdentityIntoAtpIdentity({ 171 + sourceId: currentIdentity.id, 172 + targetId: identity.id, 173 + }); 174 + if (!merged.ok) { 175 + console.error( 176 + "[oauth/callback] autoMerge failed:", 177 + merged.error, 178 + ); 179 + await stagePendingMerge(identity.id, redirectPath); 180 + } 181 + } else { 182 + await stagePendingMerge(identity.id, redirectPath); 183 + // Only reached if the token insert failed — fall through. 184 + } 140 185 } 141 186 142 187 // Trigger migration if identity needs it