134 lines
5.2 KiB
TypeScript
134 lines
5.2 KiB
TypeScript
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 (`<apiUrl>/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<typeof createAuthClient> | 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<AuthSession> {
|
|
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<void> {
|
|
// 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')
|
|
}
|
|
}
|