Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion app/app/signin/sign-in.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"));
Expand All @@ -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<unknown>) {
setBusy(true);
setError(null);
Expand Down Expand Up @@ -68,6 +77,17 @@ export function SignIn() {
<Form className="mt-6 flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); void run(() => sendCode({ email: email.trim() })); }}>
<TextField label="Email" type="email" autoComplete="email" inputMode="email" value={email} onChange={setEmail} isRequired />
<Button type="submit" full pending={busy}>Email me a code</Button>
<div className="flex items-center gap-3 text-[13px] text-text-muted" aria-hidden>
<span className="h-px flex-1 bg-border" />
or, if you&apos;ve turned it on
<span className="h-px flex-1 bg-border" />
</div>
<Button intent="secondary" full pending={oauth.status === "loading"} isDisabled={busy} onPress={() => { setError(null); initOAuth({ provider: "google", disableSignup: true }).catch(() => setError(QUICK_FAILED("Google"))); }}>
Continue with Google
</Button>
<Button intent="secondary" full isDisabled={busy} onPress={() => void run(() => loginWithPasskey().catch((e: unknown) => { throw /cancel|denied|abort|not allowed/i.test(String(e)) ? new Error("The request was cancelled.") : new Error(QUICK_FAILED("passkey")); }))}>
Sign in with a passkey
</Button>
</Form>
) : (
<Form className="mt-6 flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); void run(() => loginWithCode({ code: code.trim() })); }}>
Expand Down
2 changes: 1 addition & 1 deletion app/components/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<PrivyProvider
appId={appId}
config={{
loginMethods: ["email"],
loginMethods: ["email", "google", "passkey"],
appearance: { theme: "light", accentColor: "#1F5E4B", logo: "/img/logo-128.png", landingHeader: "Sign in to Setlo" },
embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" }, showWalletUIs: false },
defaultChain: arbitrumSepolia,
Expand Down
74 changes: 74 additions & 0 deletions app/components/app/quick-sign-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useLinkWithOAuth, useLinkWithPasskey, usePrivy } from "@privy-io/react-auth";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, Notice } from "@/components/ui/status";
import { friendly } from "@/lib/client/errors";

/** Lets a signed-in user add Google or a passkey so returning sign-ins skip the email code. */
export function QuickSignIn() {
const { user } = usePrivy();
const { initOAuth, state: oauth } = useLinkWithOAuth();
const { linkWithPasskey } = useLinkWithPasskey();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<string | null>(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 (
<Card>
<h2 className="text-lg font-semibold">Sign in faster next time</h2>
<p className="mt-1 text-[15px] text-text-muted">
Skip the email code when you come back. These sign in to this same account, so your bookings and payout account stay the same.
</p>
{done && <Notice tone="ok" className="mt-4">{done}</Notice>}
{error && <Notice tone="bad" className="mt-4">{error}</Notice>}
<div className="mt-4 flex flex-wrap gap-2">
{google ? (
<p className="text-sm text-text-muted">Google sign-in is on ({google}).</p>
) : (
<Button intent="secondary" pending={oauth.status === "loading"} isDisabled={busy} onPress={() => { setError(null); initOAuth({ provider: "google" }).catch((e: unknown) => setError(linkError(e, "Google account"))); }}>
Link Google
</Button>
)}
{passkeys > 0 ? (
<p className="text-sm text-text-muted">Passkey sign-in is on.</p>
) : (
<Button intent="secondary" pending={busy} onPress={() => void addPasskey()}>
Add a passkey
</Button>
)}
</div>
</Card>
);
}

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}.`);
}
25 changes: 19 additions & 6 deletions app/components/app/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,25 @@ 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 || 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));
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
}, 3000);
return () => clearTimeout(t);
}, [ready, authenticated, walletsReady, address, hasEmbedded, createWallet, attempt]);

useEffect(() => {
if (!authenticated || !address) return;
Expand Down Expand Up @@ -109,7 +122,7 @@ export function RequireSession({ children }: { children: React.ReactNode }) {
if (!ready || !authenticated) return <PageLoading label="Checking your sign-in" />;
if (error && !session)
return (
<ErrorState className="px-4 pt-24" title="Account setup didn't finish" action={<Button onPress={() => setAttempt((n) => n + 1)}>Try again</Button>}>
<ErrorState className="px-4 pt-24" title="Account setup didn't finish" action={<Button onPress={() => { setError(null); setAttempt((n) => n + 1); }}>Try again</Button>}>
{error}
</ErrorState>
);
Expand Down
2 changes: 2 additions & 0 deletions app/components/booking/bookings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +92,7 @@ export function Bookings() {
</ul>
)}
</section>
<QuickSignIn />
</div>
);
}
7 changes: 5 additions & 2 deletions app/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ export async function requireUser(req: Request): Promise<User> {
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, 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 };

await db()
.from("profiles")
Expand Down
Loading