Files
starter-e2e-mrf90kvm/apps/app/src/lib/auth.ts
2026-07-10 20:07:50 +02:00

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')
}
}