chore: starter template
This commit is contained in:
133
apps/app/src/lib/auth.ts
Normal file
133
apps/app/src/lib/auth.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user