FesaComponents

Auth

Autenticación better-auth con los defaults FESA — schema drizzle, factory de servidor y cliente react, sin registro público.

@fesa/components/auth empaqueta el patrón de autenticación de la casa: better-auth + drizzle sobre MySQL, con administración de cuentas por rol y sin registro público por defecto. El primer consumidor es developers.fesa.com.pa.

Peers opcionales

Este módulo requiere better-auth (≥1.4) y drizzle-orm (≥0.40) instalados en la app. Son peers opcionales: las apps que no usan auth no los necesitan.

Las tres piezas

ExportQué es
createAuthTables({ roles, defaultRole })Las cuatro tablas drizzle que better-auth necesita (users, sessions, accounts, verifications) con el enum de roles parametrizado
createFesaAuth(options)La instancia better-auth con los defaults FESA
createFesaAuthClient(options) — desde ./auth/clientEl cliente react con el plugin de administración

Defaults que vienen de fábrica

  • Registro público deshabilitado (allowSignUp: false): las cuentas las crea un administrador con el plugin admin.
  • Contraseña mínima de 8, sesión de 7 días con cookie cache de 5 minutos.
  • Rate limit por ruta: 5 intentos de login por 15 minutos, 3 resets por hora.
  • nextCookies() al final de los plugins — las Server Actions manejan cookies sin cableado extra.

Uso

db/schema.ts
import { createAuthTables } from '@fesa/components/auth'

export const ROLES = ['admin', 'programador', 'usuario'] as const

export const { users, sessions, accounts, verifications } = createAuthTables({
  roles: ROLES,
  defaultRole: 'usuario',
})
lib/auth.ts
import { createFesaAuth } from '@fesa/components/auth'
import { accounts, ROLES, sessions, users, verifications } from '@/db/schema'
import { db } from '@/lib/db'

export const auth = createFesaAuth({
  appName: 'Mi App',
  baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
  secret: process.env.BETTER_AUTH_SECRET ?? '',
  db,
  tables: { users, sessions, accounts, verifications },
  roles: ROLES,
  defaultRole: 'usuario',
  adminRoles: ['admin'],
})
app/api/auth/[...all]/route.ts
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/lib/auth'

export const { GET, POST } = toNextJsHandler(auth.handler)
lib/auth-client.ts
'use client'

import { createFesaAuthClient } from '@fesa/components/auth/client'

export const authClient = createFesaAuthClient()

Seed de la primera cuenta

Con el registro cerrado, la primera cuenta se siembra directo con drizzle. La constante CREDENTIAL_ISSUER importa: desde better-auth 1.5 el login solo matchea cuentas credenciales con issuer = 'local:credential' — sin ella el login responde 401 aunque el hash sea correcto.

scripts/seed.ts
import { randomUUID } from 'node:crypto'
import { hashPassword } from 'better-auth/crypto'
import { CREDENTIAL_ISSUER } from '@fesa/components/auth'

const userId = randomUUID()
await db.insert(users).values({ id: userId, name: 'Admin', email: '[email protected]', emailVerified: true, role: 'admin' })
await db.insert(accounts).values({
  id: randomUUID(),
  userId,
  providerId: 'credential',
  issuer: CREDENTIAL_ISSUER,
  accountId: userId,
  password: await hashPassword(process.env.SEED_ADMIN_PASSWORD!),
})

Administración de usuarios

El plugin admin viene activo: el cliente expone authClient.admin.createUser, setRole, banUser / unbanUser, setUserPassword, removeUser y listUsers (en servidor, auth.api.listUsers con los headers de la petición). El patrón FESA: página server que lista y componente cliente que muta y hace router.refresh().

Roles y tipos del cliente

El adminClient genérico tipa role como 'user' | 'admin'. Con roles propios, castea en el punto de llamada — el servidor valida contra el enum real de la tabla.

Qué NO trae (a propósito)

  • Ni páginas de login ni UI: cada app arma su formulario con su marca (el de developers son ~90 líneas).
  • Ni verificación de email ni 2FA por defecto: para herramientas internas con cuentas creadas por un admin es fricción sin beneficio. Se agregan por plugins si un caso lo pide.
  • Ni migraciones: cada app corre drizzle-kit push o genera las suyas contra su base.

En esta pagina