From 4520a77c8781c23a9fd5bc0777254ccc4133c961 Mon Sep 17 00:00:00 2001 From: adepojuemmanuel235 Date: Sat, 3 Oct 2026 14:44:42 +0000 Subject: [PATCH 1/2] Google and passkey sign-in for returning users; stop duplicate embedded wallets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/app/signin/sign-in.tsx | 22 ++++++++- app/components/app/providers.tsx | 2 +- app/components/app/quick-sign-in.tsx | 74 ++++++++++++++++++++++++++++ app/components/app/session.tsx | 18 +++++-- app/components/booking/bookings.tsx | 2 + app/lib/auth.ts | 6 ++- 6 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 app/components/app/quick-sign-in.tsx diff --git a/app/app/signin/sign-in.tsx b/app/app/signin/sign-in.tsx index b35eab4..cd1eb3e 100644 --- a/app/app/signin/sign-in.tsx +++ b/app/app/signin/sign-in.tsx @@ -1,6 +1,6 @@ "use client"; -import { useLoginWithEmail, usePrivy } from "@privy-io/react-auth"; +import { useLoginWithEmail, useLoginWithOAuth, useLoginWithPasskey, usePrivy } from "@privy-io/react-auth"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -16,9 +16,14 @@ function safeNext(raw: string | null) { return raw && raw.startsWith("/") && !raw.startsWith("//") ? raw : "/app"; } +const QUICK_FAILED = (method: "Google" | "passkey") => + `Couldn't sign in with ${method === "Google" ? "Google" : "a passkey"}. If you haven't turned on ${method} sign-in for your Setlo account yet, sign in with an email code first, then turn it on from your bookings page.`; + export function SignIn() { const { ready, authenticated } = usePrivy(); const { sendCode, loginWithCode, state } = useLoginWithEmail(); + const { initOAuth, state: oauth } = useLoginWithOAuth(); + const { loginWithPasskey } = useLoginWithPasskey(); const router = useRouter(); const params = useSearchParams(); const next = safeNext(params.get("next")); @@ -34,6 +39,10 @@ export function SignIn() { if (ready && authenticated) router.replace(next); }, [ready, authenticated, router, next]); + useEffect(() => { + if (oauth.status === "error") setError(QUICK_FAILED("Google")); + }, [oauth.status]); + async function run(fn: () => Promise) { setBusy(true); setError(null); @@ -68,6 +77,17 @@ export function SignIn() {
{ e.preventDefault(); void run(() => sendCode({ email: email.trim() })); }}> +
+ + or, if you've turned it on + +
+ + ) : (
{ e.preventDefault(); void run(() => loginWithCode({ code: code.trim() })); }}> diff --git a/app/components/app/providers.tsx b/app/components/app/providers.tsx index fa532df..b7937f2 100644 --- a/app/components/app/providers.tsx +++ b/app/components/app/providers.tsx @@ -17,7 +17,7 @@ export function Providers({ children }: { children: React.ReactNode }) { (null); + const [done, setDone] = useState(null); + + useEffect(() => { + if (oauth.status === "error") setError(linkError(oauth.error, "Google account")); + if (oauth.status === "done") setDone("Google sign-in is on. Next time, choose Continue with Google."); + }, [oauth]); + + if (!user) return null; + const google = user.google?.email; + const passkeys = user.linkedAccounts.filter((a) => a.type === "passkey").length; + if (google && passkeys > 0 && !done) return null; + + async function addPasskey() { + setBusy(true); + setError(null); + try { + await linkWithPasskey({ name: "Setlo" }); + setDone("Passkey added. Next time, choose Sign in with a passkey on this device."); + } catch (e) { + setError(linkError(e, "passkey")); + } finally { + setBusy(false); + } + } + + return ( + +

Sign in faster next time

+

+ Skip the email code when you come back. These sign in to this same account, so your bookings and payout account stay the same. +

+ {done && {done}} + {error && {error}} +
+ {google ? ( +

Google sign-in is on ({google}).

+ ) : ( + + )} + {passkeys > 0 ? ( +

Passkey sign-in is on.

+ ) : ( + + )} +
+
+ ); +} + +function linkError(e: unknown, what: string) { + const msg = e instanceof Error ? e.message : String(e ?? ""); + if (/linked_to_another_user|already.*(linked|associated)/i.test(msg)) return `That ${what} is already used by a different Setlo account.`; + if (/cancel|denied|abort|not allowed/i.test(msg)) return "The request was cancelled."; + return friendly(e ?? `Couldn't link the ${what}.`); +} diff --git a/app/components/app/session.tsx b/app/components/app/session.tsx index 50427b0..744fd47 100644 --- a/app/components/app/session.tsx +++ b/app/components/app/session.tsx @@ -56,12 +56,20 @@ export function RequireSession({ children }: { children: React.ReactNode }) { const embedded = wallets.find((w) => w.walletClientType === "privy"); const address = (user?.wallet?.address ?? embedded?.address) as Address | undefined; + const hasEmbedded = !!user?.linkedAccounts.some((a) => a.type === "wallet" && a.walletClientType === "privy"); + const creating = useRef(false); useEffect(() => { - if (!ready || !authenticated || !walletsReady || address) return; - createWallet().catch((e: unknown) => { - if (!String(e).includes("already has")) setError(friendly(e)); - }); - }, [ready, authenticated, walletsReady, address, createWallet]); + if (!authenticated) creating.current = false; + if (!ready || !authenticated || !walletsReady || address || hasEmbedded || creating.current) return; + // Privy's createOnLogin usually provisions the wallet; this is a single delayed fallback. + const t = setTimeout(() => { + creating.current = true; + createWallet().catch((e: unknown) => { + if (!String(e).includes("already has")) setError(friendly(e)); + }); + }, 3000); + return () => clearTimeout(t); + }, [ready, authenticated, walletsReady, address, hasEmbedded, createWallet]); useEffect(() => { if (!authenticated || !address) return; diff --git a/app/components/booking/bookings.tsx b/app/components/booking/bookings.tsx index a602752..11179be 100644 --- a/app/components/booking/bookings.tsx +++ b/app/components/booking/bookings.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; +import { QuickSignIn } from "@/components/app/quick-sign-in"; import { useSession } from "@/components/app/session"; import { NetworkBadge } from "@/components/app/shell"; import { Button, LinkButton } from "@/components/ui/button"; @@ -91,6 +92,7 @@ export function Bookings() { )} + ); } diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 6d88859..91bd467 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -33,8 +33,10 @@ export async function requireUser(req: Request): Promise { if (!res.ok) throw new HttpError(401, "unknown user"); const accounts = ((await res.json()) as { linked_accounts: LinkedAccount[] }).linked_accounts; const email = accounts.find((a) => a.type === "email")?.address?.toLowerCase() ?? null; - const wallet = accounts.find((a) => a.type === "wallet" && a.wallet_client_type === "privy")?.address; - const user: User = { id: sub, email, wallet: wallet ? getAddress(wallet) : null }; + const embedded = accounts.filter((a) => a.type === "wallet" && a.wallet_client_type === "privy" && a.address).map((a) => getAddress(a.address!)); + const { data: prev } = await db().from("profiles").select("wallet").eq("privy_id", sub).maybeSingle(); + const registered = embedded.find((w) => w.toLowerCase() === (prev?.wallet as string | null | undefined)?.toLowerCase()); + const user: User = { id: sub, email, wallet: registered ?? embedded[0] ?? null }; await db() .from("profiles") From 6ac303abcd79f489c5d581543bfcc2c8fb7e8613 Mon Sep 17 00:00:00 2001 From: adepojuemmanuel235 Date: Sat, 3 Oct 2026 15:06:58 +0000 Subject: [PATCH 2/2] Retry wallet provisioning; fail closed on profile read; surface unloaded existing wallet Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/components/app/session.tsx | 11 ++++++++--- app/lib/auth.ts | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/components/app/session.tsx b/app/components/app/session.tsx index 744fd47..8fd7451 100644 --- a/app/components/app/session.tsx +++ b/app/components/app/session.tsx @@ -60,16 +60,21 @@ export function RequireSession({ children }: { children: React.ReactNode }) { const creating = useRef(false); useEffect(() => { if (!authenticated) creating.current = false; - if (!ready || !authenticated || !walletsReady || address || hasEmbedded || creating.current) return; + if (!ready || !authenticated || !walletsReady || address || creating.current) return; + if (hasEmbedded) { + const t = setTimeout(() => setError("Your payout account didn't load in this browser. Try again, or sign out and sign in again."), 15000); + return () => clearTimeout(t); + } // Privy's createOnLogin usually provisions the wallet; this is a single delayed fallback. const t = setTimeout(() => { creating.current = true; createWallet().catch((e: unknown) => { + creating.current = false; if (!String(e).includes("already has")) setError(friendly(e)); }); }, 3000); return () => clearTimeout(t); - }, [ready, authenticated, walletsReady, address, hasEmbedded, createWallet]); + }, [ready, authenticated, walletsReady, address, hasEmbedded, createWallet, attempt]); useEffect(() => { if (!authenticated || !address) return; @@ -117,7 +122,7 @@ export function RequireSession({ children }: { children: React.ReactNode }) { if (!ready || !authenticated) return ; if (error && !session) return ( - setAttempt((n) => n + 1)}>Try again}> + { setError(null); setAttempt((n) => n + 1); }}>Try again}> {error} ); diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 91bd467..cf3ac4a 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -34,7 +34,8 @@ export async function requireUser(req: Request): Promise { const accounts = ((await res.json()) as { linked_accounts: LinkedAccount[] }).linked_accounts; const email = accounts.find((a) => a.type === "email")?.address?.toLowerCase() ?? null; const embedded = accounts.filter((a) => a.type === "wallet" && a.wallet_client_type === "privy" && a.address).map((a) => getAddress(a.address!)); - const { data: prev } = await db().from("profiles").select("wallet").eq("privy_id", sub).maybeSingle(); + const { data: prev, error: prevError } = await db().from("profiles").select("wallet").eq("privy_id", sub).maybeSingle(); + if (prevError) throw new HttpError(503, "Couldn't load your account. Try again."); const registered = embedded.find((w) => w.toLowerCase() === (prev?.wallet as string | null | undefined)?.toLowerCase()); const user: User = { id: sub, email, wallet: registered ?? embedded[0] ?? null };