import { createAuthClient } from 'better-auth/client' import { apiUrl } from './env' // Better Auth browser client. It targets the worker's catch-all `/api/auth/**` // routes (generated from src/auth.ts in the functions package). Pass the FULL // auth base (`/auth`): better-auth's withPath only appends its default // `/api/auth` when baseURL has no path, and apiUrl() already carries `/api`, so // a bare apiUrl() would leave the client calling `/api/get-session` (404) and // the app would loop back to /login. Cookies ride every request so the session // round-trips across origins. // // Lazily constructed: createAuthClient validates baseURL with `new URL()` at // construction, so building it at module scope crashes SSR (apiUrl() returns // the relative `/__api` placeholder there). Every caller runs in the browser. let _authClient: ReturnType | null = null function authClient() { _authClient ??= createAuthClient({ baseURL: `${apiUrl()}/auth` }) return _authClient } export type AuthSession = { user?: { email?: string | null name?: string | null image?: string | null } | null expires?: string } // Thrown by signInWithPassword when the email/password pair is wrong. export const INVALID_CREDENTIALS = 'INVALID_CREDENTIALS' // Thrown by registerWithPassword when the email is already taken. export const EMAIL_IN_USE = 'EMAIL_IN_USE' export async function getAuthSession(): Promise { const { data } = await authClient().getSession() if (!data?.user) { return {} } return { user: { email: data.user.email, name: data.user.name ?? null, image: data.user.image ?? null, }, expires: data.session?.expiresAt ? new Date(data.session.expiresAt).toISOString() : undefined, } } // Sign in with an email and password. On success Better Auth sets the session // cookie; on bad credentials this throws INVALID_CREDENTIALS. export async function signInWithPassword(email: string, password: string, _redirectPath = '/app') { const { error } = await authClient().signIn.email({ email, password }) if (error) { throw new Error(INVALID_CREDENTIALS) } } // Create a new account. Better Auth signs the user in on success (sets the // session cookie), so they land logged in. export async function registerWithPassword( email: string, password: string, options: { name?: string; redirectPath?: string } = {}, ) { const { error } = await authClient().signUp.email({ email, password, // Better Auth requires a name; fall back to the local-part of the email. name: options.name?.trim() || email.split('@')[0], }) if (error) { // Better Auth returns 422 (UNPROCESSABLE_ENTITY) when the email is taken. if (error.status === 422 || /exist|taken/i.test(error.message ?? '')) { throw new Error(EMAIL_IN_USE) } throw new Error('Unable to create account') } } // Dev-only convenience account. The Quick-login button (rendered only in non-prod // builds — sandboxes run `vite dev`, deployments are production builds where // import.meta.env.DEV is false) signs in with these so you never sign up by hand. export const DEV_LOGIN = { email: 'test@example.com', password: 'password' } as const // Sign in with the dev account, provisioning it on first use. Idempotent: if the // account already exists we just sign in; a concurrent create (EMAIL_IN_USE) is // fine. Only ever called from the dev-gated Quick-login button. export async function devQuickLogin(): Promise { // The account usually already exists — try signing in first. A failure here is // expected on the very first run (not yet provisioned), so fall through. const signedIn = await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password) .then(() => true) .catch(() => false) if (signedIn) return await registerWithPassword(DEV_LOGIN.email, DEV_LOGIN.password, { name: 'Test User' }).catch( (err) => { // Another tab/request may have created it between our sign-in and sign-up. if (!(err instanceof Error && err.message === EMAIL_IN_USE)) throw err }, ) await signInWithPassword(DEV_LOGIN.email, DEV_LOGIN.password) } // Continue with Google. Better Auth redirects the browser to the provider and // back to `callbackURL` on success, so this never resolves on the happy path — // it throws only when the provider isn't configured / the request is rejected. // Configure the Google provider in the functions package's auth config to enable // it; until then the button surfaces the error via useMutation. export async function signInWithGoogle(callbackURL = '/app') { const { error } = await authClient().signIn.social({ provider: 'google', callbackURL }) if (error) { throw new Error('Unable to continue with Google') } } // Change the signed-in user's password. Better Auth requires the current // password to authorize the change. export async function changePassword(currentPassword: string, newPassword: string) { const { error } = await authClient().changePassword({ currentPassword, newPassword }) if (error) { throw new Error('Unable to update password') } } export async function signOut() { const { error } = await authClient().signOut() if (error) { throw new Error('Unable to sign out') } }