diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000..3458e07 --- /dev/null +++ b/.agents/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,166 @@ +--- +name: better-auth-best-practices +description: Skill for integrating Better Auth - the comprehensive TypeScript authentication framework. +--- + +# Better Auth Integration Guide + +**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.** + +Better Auth is a TypeScript-first, framework-agnostic auth framework supporting email/password, OAuth, magic links, passkeys, and more via plugins. + +--- + +## Quick Reference + +### Environment Variables +- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` +- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) + +Only define `baseURL`/`secret` in config if env vars are NOT set. + +### File Location +CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. + +### CLI Commands +- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter) +- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle +- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools + +**Re-run after adding/changing plugins.** + +--- + +## Core Config Options + +| Option | Notes | +|--------|-------| +| `appName` | Optional display name | +| `baseURL` | Only if `BETTER_AUTH_URL` not set | +| `basePath` | Default `/api/auth`. Set `/` for root. | +| `secret` | Only if `BETTER_AUTH_SECRET` not set | +| `database` | Required for most features. See adapters docs. | +| `secondaryStorage` | Redis/KV for sessions & rate limits | +| `emailAndPassword` | `{ enabled: true }` to activate | +| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | +| `plugins` | Array of plugins | +| `trustedOrigins` | CSRF whitelist | + +--- + +## Database + +**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. + +**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. + +**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. + +--- + +## Session Management + +**Storage priority:** +1. If `secondaryStorage` defined → sessions go there (not DB) +2. Set `session.storeSessionInDatabase: true` to also persist to DB +3. No database + `cookieCache` → fully stateless mode + +**Cookie cache strategies:** +- `compact` (default) - Base64url + HMAC. Smallest. +- `jwt` - Standard JWT. Readable but signed. +- `jwe` - Encrypted. Maximum security. + +**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). + +--- + +## User & Account Config + +**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). + +**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). + +**Required for registration:** `email` and `name` fields. + +--- + +## Email Flows + +- `emailVerification.sendVerificationEmail` - Must be defined for verification to work +- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers +- `emailAndPassword.sendResetPassword` - Password reset email handler + +--- + +## Security + +**In `advanced`:** +- `useSecureCookies` - Force HTTPS cookies +- `disableCSRFCheck` - ⚠️ Security risk +- `disableOriginCheck` - ⚠️ Security risk +- `crossSubDomainCookies.enabled` - Share cookies across subdomains +- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies +- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` + +**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). + +--- + +## Hooks + +**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. + +**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. + +**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. + +--- + +## Plugins + +**Import from dedicated paths for tree-shaking:** +``` +import { twoFactor } from "better-auth/plugins/two-factor" +``` +NOT `from "better-auth/plugins"`. + +**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. + +Client plugins go in `createAuthClient({ plugins: [...] })`. + +--- + +## Client + +Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. + +Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. + +--- + +## Type Safety + +Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. + +For separate client/server projects: `createAuthClient()`. + +--- + +## Common Gotchas + +1. **Model vs table name** - Config uses ORM model name, not DB table name +2. **Plugin schema** - Re-run CLI after adding plugins +3. **Secondary storage** - Sessions go there by default, not DB +4. **Cookie cache** - Custom session fields NOT cached, always re-fetched +5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry +6. **Change email flow** - Sends to current email first, then new email + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Options Reference](https://better-auth.com/docs/reference/options) +- [LLMs.txt](https://better-auth.com/llms.txt) +- [GitHub](https://github.com/better-auth/better-auth) +- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) \ No newline at end of file diff --git a/.agents/skills/better-auth-security-best-practices/SKILL.MD b/.agents/skills/better-auth-security-best-practices/SKILL.MD new file mode 100644 index 0000000..11249c5 --- /dev/null +++ b/.agents/skills/better-auth-security-best-practices/SKILL.MD @@ -0,0 +1,644 @@ +--- +name: better-auth-security-best-practices +description: This skill provides guidance for implementing security features that span across Better Auth, including rate limiting, CSRF protection, session security, trusted origins, secret management, OAuth security, IP tracking, and security auditing. These topics are not covered in individual plugin skills. +--- + +## Secret Management + +The auth secret is the foundation of Better Auth's security. It's used for signing session tokens, encrypting sensitive data, and generating secure cookies. + +### Configuring the Secret + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + secret: process.env.BETTER_AUTH_SECRET, // or via `BETTER_AUTH_SECRET` env +}); +``` + +Better Auth looks for secrets in this order: +1. `options.secret` in your config +2. `BETTER_AUTH_SECRET` environment variable +3. `AUTH_SECRET` environment variable + +### Secret Requirements + +Better Auth validates your secret and will: +- **Reject** default/placeholder secrets in production +- **Warn** if the secret is shorter than 32 characters +- **Warn** if entropy is below 120 bits + +Generate a secure secret: + +```bash +openssl rand -base64 32 +``` + +**Important**: Never commit secrets to version control. Use environment variables or a secrets manager. + +## Rate Limiting + +Rate limiting protects your authentication endpoints from brute-force attacks and abuse. +By default, rate limiting is enabled in production but disabled in development. To explicitly enable it, set `rateLimit.enabled` to `true` in your auth config. +Better Auth applies rate limiting to all endpoints by default. + +Each plugin can optionally have it's own configuration to adjust rate-limit rules for a given endpoint. + +### Default Configuration + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + rateLimit: { + enabled: true, // Default: true in production + window: 10, // Time window in seconds (default: 10) + max: 100, // Max requests per window (default: 100) + }, +}); +``` + +### Storage Options + +Configure where rate limit counters are stored: + +```ts +rateLimit: { + storage: "database", // Options: "memory", "database", "secondary-storage" +} +``` + +- **`memory`**: Fast, but resets on server restart (default when no secondary storage) +- **`database`**: Persistent, but adds database load +- **`secondary-storage`**: Uses configured secondary storage like Redis (default when available) + +**Note**: It is not recommended to use `memory` especially on serverless platforms. + +### Custom Storage + +Implement your own rate limit storage: + +```ts +rateLimit: { + customStorage: { + get: async (key) => { + // Return { count: number, expiresAt: number } or null + }, + set: async (key, data) => { + // Store the rate limit data + }, + }, +} +``` + +### Per-Endpoint Rules + +Better Auth applies stricter limits to sensitive endpoints by default: +- `/sign-in`, `/sign-up`, `/change-password`, `/change-email`: 3 requests per 10 seconds + +Override or customize rules for specific paths: + +```ts +rateLimit: { + customRules: { + "/api/auth/sign-in/email": { + window: 60, // 1 minute window + max: 5, // 5 attempts + }, + "/api/auth/some-safe-endpoint": false, // Disable rate limiting + }, +} +``` + +## CSRF Protection + +Better Auth implements multiple layers of CSRF protection to prevent cross-site request forgery attacks. + +### How CSRF Protection Works + +1. **Origin Header Validation**: When cookies are present, the `Origin` or `Referer` header must match a trusted origin +2. **Fetch Metadata**: Uses `Sec-Fetch-Site`, `Sec-Fetch-Mode`, and `Sec-Fetch-Dest` headers to detect cross-site requests +3. **First-Login Protection**: Even without cookies, validates origin when Fetch Metadata indicates a cross-site navigation + +### Configuration + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + advanced: { + disableCSRFCheck: false, // Default: false (keep enabled) + }, +}); +``` + +**Warning**: Only disable CSRF protection for testing or if you have an alternative CSRF mechanism in place. + +### Fetch Metadata Blocking + +Better Auth automatically blocks requests where: +- `Sec-Fetch-Site: cross-site` AND +- `Sec-Fetch-Mode: navigate` AND +- `Sec-Fetch-Dest: document` + +This prevents form-based CSRF attacks even on first login when no session cookie exists. + +## Trusted Origins + +Trusted origins control which domains can make authenticated requests to your Better Auth instance. This protects against open redirect attacks and cross-origin abuse. + +### Configuring Trusted Origins + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + baseURL: "https://api.example.com", + trustedOrigins: [ + "https://app.example.com", + "https://admin.example.com", + ], +}); +``` + +**Note**: The `baseURL` origin is automatically trusted. + +### Environment Variable + +Set trusted origins via environment variable (comma-separated): + +```bash +BETTER_AUTH_TRUSTED_ORIGINS=https://app.example.com,https://admin.example.com +``` + +### Wildcard Patterns + +Support for subdomain wildcards: + +```ts +trustedOrigins: [ + "*.example.com", // Matches any subdomain + "https://*.example.com", // Protocol-specific wildcard + "exp://192.168.*.*:*/*", // Custom schemes (e.g., Expo) +] +``` + +### Dynamic Trusted Origins + +Compute trusted origins based on the request: + +```ts +trustedOrigins: async (request) => { + // Validate against database, header, etc. + const tenant = getTenantFromRequest(request); + return [`https://${tenant}.myapp.com`]; +} +``` + +### What Gets Validated + +Better Auth validates these URL parameters against trusted origins: +- `callbackURL` - Where to redirect after authentication +- `redirectTo` - General redirect parameter +- `errorCallbackURL` - Where to redirect on errors +- `newUserCallbackURL` - Where to redirect new users +- `origin` - Request origin header +- and more... + +Invalid URLs receive a 403 Forbidden response. + +## Session Security + +Sessions control how long users stay authenticated and how session data is secured. + +### Session Expiration + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days (default) + updateAge: 60 * 60 * 24, // Refresh session every 24 hours (default) + }, +}); +``` + +### Fresh Sessions for Sensitive Actions + +The `freshAge` setting defines how recently a user must have authenticated to perform sensitive operations: + +```ts +session: { + freshAge: 60 * 60 * 24, // 24 hours (default) +} +``` + +Use this to require re-authentication for actions like changing passwords or viewing sensitive data. + +### Session Caching Strategies + +Cache session data in cookies to reduce database queries: + +```ts +session: { + cookieCache: { + enabled: true, + maxAge: 60 * 5, // 5 minutes + strategy: "compact", // Options: "compact", "jwt", "jwe" + }, +} +``` + +- **`compact`**: Base64url + HMAC-SHA256 (smallest, signed) +- **`jwt`**: HS256 JWT (standard, signed) +- **`jwe`**: A256CBC-HS512 encrypted (largest, encrypted) + +**Note**: Use `jwe` strategy when session data contains sensitive information that shouldn't be readable client-side. + + +## Cookie Security + +Better Auth uses secure cookie defaults but allows customization for specific deployment scenarios. + +### Default Cookie Settings + +- **`secure`**: `true` when baseURL uses HTTPS or in production +- **`sameSite`**: `"lax"` (prevents CSRF while allowing normal navigation) +- **`httpOnly`**: `true` (prevents JavaScript access) +- **`path`**: `"/"` (available site-wide) +- **Prefix**: `__Secure-` when secure is enabled + +### Custom Cookie Configuration + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + advanced: { + useSecureCookies: true, // Force secure cookies + cookiePrefix: "myapp", // Custom prefix (default: "better-auth") + defaultCookieAttributes: { + sameSite: "strict", // Stricter CSRF protection + path: "/auth", // Limit cookie scope + }, + }, +}); +``` + +### Per-Cookie Configuration + +Customize specific cookies: + +```ts +advanced: { + cookies: { + session_token: { + name: "auth-session", + attributes: { + sameSite: "strict", + }, + }, + }, +} +``` + +### Cross-Subdomain Cookies + +Share authentication across subdomains: + +```ts +advanced: { + crossSubDomainCookies: { + enabled: true, + domain: ".example.com", // Note the leading dot + additionalCookies: ["session_token", "session_data"], + }, +} +``` + +**Security Note**: Cross-subdomain cookies expand the attack surface. Only enable if you need authentication sharing and trust all subdomains. + +## OAuth / Social Provider Security + +When using social login providers, Better Auth implements industry-standard security measures. + +### PKCE (Proof Key for Code Exchange) + +Better Auth automatically uses PKCE for all OAuth flows: + +1. Generates a 128-character random `code_verifier` +2. Creates a `code_challenge` using S256 (SHA-256) +3. Sends `code_challenge_method: "S256"` in the authorization URL +4. Validates the code exchange with the original verifier + +This prevents authorization code interception attacks. + +### State Parameter Security + +The state parameter prevents CSRF attacks on OAuth callbacks: + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + account: { + storeStateStrategy: "cookie", // Options: "cookie" (default), "database" + }, +}); +``` + +State tokens: +- Are 32-character random strings +- Expire after 10 minutes +- Contain callback URLs and PKCE verifier (encrypted) + +### Encrypting OAuth Tokens + +Encrypt stored access and refresh tokens in the database: + +```ts +account: { + encryptOAuthTokens: true, // Uses AES-256-GCM +} +``` + +**Recommendation**: Enable this if you store OAuth tokens for API access on behalf of users. + +### Skipping State Cookie Check + +For mobile apps or specific OAuth flows where cookies aren't available: + +```ts +account: { + skipStateCookieCheck: true, // Not recommended for web apps +} +``` + +**Warning**: Only use this for mobile apps that cannot maintain cookies across redirects. + +## IP-Based Security + +Better Auth tracks IP addresses for rate limiting and session security. + +### IP Address Configuration + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + advanced: { + ipAddress: { + ipAddressHeaders: ["x-forwarded-for", "x-real-ip"], // Headers to check + disableIpTracking: false, // Keep enabled for rate limiting + }, + }, +}); +``` + +### IPv6 Subnet Configuration + +For rate limiting, IPv6 addresses can be grouped by subnet: + +```ts +advanced: { + ipAddress: { + ipv6Subnet: 64, // Options: 128, 64, 48, 32 (default: 64) + }, +} +``` + +Smaller values group more addresses together, which is useful when users share IPv6 prefixes. + +### Trusted Proxy Headers + +When behind a reverse proxy, enable trusted headers: + +```ts +advanced: { + trustedProxyHeaders: true, // Trust x-forwarded-host, x-forwarded-proto +} +``` + +**Security Note**: Only enable this if you trust your proxy. Malicious clients could spoof these headers otherwise. + +## Database Hooks for Security Auditing + +Use database hooks to implement security auditing and monitoring. + +### Setting Up Audit Logging + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + databaseHooks: { + session: { + create: { + after: async ({ data, ctx }) => { + await auditLog("session.created", { + userId: data.userId, + ip: ctx?.request?.headers.get("x-forwarded-for"), + userAgent: ctx?.request?.headers.get("user-agent"), + }); + }, + }, + delete: { + before: async ({ data }) => { + await auditLog("session.revoked", { sessionId: data.id }); + }, + }, + }, + user: { + update: { + after: async ({ data, oldData }) => { + if (oldData?.email !== data.email) { + await auditLog("user.email_changed", { + userId: data.id, + oldEmail: oldData?.email, + newEmail: data.email, + }); + } + }, + }, + }, + account: { + create: { + after: async ({ data }) => { + await auditLog("account.linked", { + userId: data.userId, + provider: data.providerId, + }); + }, + }, + }, + }, +}); +``` + +### Blocking Operations + +Return `false` from a `before` hook to prevent an operation: + +```ts +databaseHooks: { + user: { + delete: { + before: async ({ data }) => { + // Prevent deletion of protected users + if (protectedUserIds.includes(data.id)) { + return false; + } + }, + }, + }, +} +``` + +## Background Tasks for Timing Attack Prevention + +Sensitive operations should complete in constant time to prevent timing attacks. + +### Configuring Background Tasks + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + advanced: { + backgroundTasks: { + handler: (promise) => { + // Platform-specific handler + // Vercel: waitUntil(promise) + // Cloudflare: ctx.waitUntil(promise) + waitUntil(promise); + }, + }, + }, +}); +``` + +This ensures operations like sending emails don't affect response timing, which could leak information about whether a user exists. + +## Account Enumeration Prevention + +Better Auth implements several measures to prevent attackers from discovering valid accounts. + +### Built-in Protections + +1. **Consistent Response Messages**: Password reset always returns "If this email exists in our system, check your email for the reset link" +2. **Dummy Operations**: When a user isn't found, Better Auth still performs token generation and database lookups with dummy values +3. **Background Email Sending**: Emails are sent asynchronously to prevent timing differences + +### Additional Recommendations + +For sign-up and sign-in endpoints, consider: + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + // Generic error messages (implement in your error handling) + }, +}); +``` + +Return generic error messages like "Invalid credentials" rather than "User not found" or "Incorrect password". + +## Complete Security Configuration Example + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + secret: process.env.BETTER_AUTH_SECRET, + baseURL: "https://api.example.com", + trustedOrigins: [ + "https://app.example.com", + "https://*.preview.example.com", + ], + + // Rate limiting + rateLimit: { + enabled: true, + storage: "secondary-storage", + customRules: { + "/api/auth/sign-in/email": { window: 60, max: 5 }, + "/api/auth/sign-up/email": { window: 60, max: 3 }, + }, + }, + + // Session security + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // 24 hours + freshAge: 60 * 60, // 1 hour for sensitive actions + cookieCache: { + enabled: true, + maxAge: 300, + strategy: "jwe", // Encrypted session data + }, + }, + + // OAuth security + account: { + encryptOAuthTokens: true, + storeStateStrategy: "cookie", + }, + + + // Advanced settings + advanced: { + useSecureCookies: true, + cookiePrefix: "myapp", + defaultCookieAttributes: { + sameSite: "lax", + }, + ipAddress: { + ipAddressHeaders: ["x-forwarded-for"], + ipv6Subnet: 64, + }, + backgroundTasks: { + handler: (promise) => waitUntil(promise), + }, + }, + + // Security auditing + databaseHooks: { + session: { + create: { + after: async ({ data, ctx }) => { + console.log(`New session for user ${data.userId}`); + }, + }, + }, + user: { + update: { + after: async ({ data, oldData }) => { + if (oldData?.email !== data.email) { + console.log(`Email changed for user ${data.id}`); + } + }, + }, + }, + }, +}); +``` + +## Security Checklist + +Before deploying to production: + +- [ ] **Secret**: Use a strong, unique secret (32+ characters, high entropy) +- [ ] **HTTPS**: Ensure `baseURL` uses HTTPS +- [ ] **Trusted Origins**: Configure all valid origins (frontend, mobile apps) +- [ ] **Rate Limiting**: Keep enabled with appropriate limits +- [ ] **CSRF Protection**: Keep enabled (`disableCSRFCheck: false`) +- [ ] **Secure Cookies**: Enabled automatically with HTTPS +- [ ] **OAuth Tokens**: Consider `encryptOAuthTokens: true` if storing tokens +- [ ] **Background Tasks**: Configure for serverless platforms +- [ ] **Audit Logging**: Implement via `databaseHooks` or `hooks` +- [ ] **IP Tracking**: Configure headers if behind a proxy diff --git a/.agents/skills/create-auth-skill/SKILL.md b/.agents/skills/create-auth-skill/SKILL.md new file mode 100644 index 0000000..c99f6dd --- /dev/null +++ b/.agents/skills/create-auth-skill/SKILL.md @@ -0,0 +1,321 @@ +--- +name: create-auth-skill +description: Skill for creating auth layers in TypeScript/JavaScript apps using Better Auth. +--- + +# Create Auth Skill + +Guide for adding authentication to TypeScript/JavaScript applications using Better Auth. + +**For code examples and syntax, see [better-auth.com/docs](https://better-auth.com/docs).** + +--- + +## Phase 1: Planning (REQUIRED before implementation) + +Before writing any code, gather requirements by scanning the project and asking the user structured questions. This ensures the implementation matches their needs. + +### Step 1: Scan the project + +Analyze the codebase to auto-detect: +- **Framework** — Look for `next.config`, `svelte.config`, `nuxt.config`, `astro.config`, `vite.config`, or Express/Hono entry files. +- **Database/ORM** — Look for `prisma/schema.prisma`, `drizzle.config`, `package.json` deps (`pg`, `mysql2`, `better-sqlite3`, `mongoose`, `mongodb`). +- **Existing auth** — Look for existing auth libraries (`next-auth`, `lucia`, `clerk`, `supabase/auth`, `firebase/auth`) in `package.json` or imports. +- **Package manager** — Check for `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, or `package-lock.json`. + +Use what you find to pre-fill defaults and skip questions you can already answer. + +### Step 2: Ask planning questions + +Use the `AskQuestion` tool to ask the user **all applicable questions in a single call**. Skip any question you already have a confident answer for from the scan. Group them under a title like "Auth Setup Planning". + +**Questions to ask:** + +1. **Project type** (skip if detected) + - Prompt: "What type of project is this?" + - Options: New project from scratch | Adding auth to existing project | Migrating from another auth library + +2. **Framework** (skip if detected) + - Prompt: "Which framework are you using?" + - Options: Next.js (App Router) | Next.js (Pages Router) | SvelteKit | Nuxt | Astro | Express | Hono | SolidStart | Other + +3. **Database & ORM** (skip if detected) + - Prompt: "Which database setup will you use?" + - Options: PostgreSQL (Prisma) | PostgreSQL (Drizzle) | PostgreSQL (pg driver) | MySQL (Prisma) | MySQL (Drizzle) | MySQL (mysql2 driver) | SQLite (Prisma) | SQLite (Drizzle) | SQLite (better-sqlite3 driver) | MongoDB (Mongoose) | MongoDB (native driver) + +4. **Authentication methods** (always ask, allow multiple) + - Prompt: "Which sign-in methods do you need?" + - Options: Email & password | Social OAuth (Google, GitHub, etc.) | Magic link (passwordless email) | Passkey (WebAuthn) | Phone number + - `allow_multiple: true` + +5. **Social providers** (only if they selected Social OAuth above — ask in a follow-up call) + - Prompt: "Which social providers do you need?" + - Options: Google | GitHub | Apple | Microsoft | Discord | Twitter/X + - `allow_multiple: true` + +6. **Email verification** (only if Email & password was selected above — ask in a follow-up call) + - Prompt: "Do you want to require email verification?" + - Options: Yes | No + +7. **Email provider** (only if email verification is Yes, or if Password reset is selected in features — ask in a follow-up call) + - Prompt: "How do you want to send emails?" + - Options: Resend | Mock it for now (console.log) + +8. **Features & plugins** (always ask, allow multiple) + - Prompt: "Which additional features do you need?" + - Options: Two-factor authentication (2FA) | Organizations / teams | Admin dashboard | API bearer tokens | Password reset | None of these + - `allow_multiple: true` + +9. **Auth pages** (always ask, allow multiple — pre-select based on earlier answers) + - Prompt: "Which auth pages do you need?" + - Options vary based on previous answers: + - Always available: Sign in | Sign up + - If Email & password selected: Forgot password | Reset password + - If email verification enabled: Email verification + - `allow_multiple: true` + +10. **Auth UI style** (always ask) + - Prompt: "What style do you want for the auth pages? Pick one or describe your own." + - Options: Minimal & clean | Centered card with background | Split layout (form + hero image) | Floating / glassmorphism | Other (I'll describe) + +### Step 3: Summarize the plan + +After collecting answers, present a concise implementation plan as a markdown checklist. Example: + +``` +## Auth Implementation Plan + +- **Framework:** Next.js (App Router) +- **Database:** PostgreSQL via Prisma +- **Auth methods:** Email/password, Google OAuth, GitHub OAuth +- **Plugins:** 2FA, Organizations, Email verification +- **UI:** Custom forms + +### Steps +1. Install `better-auth` and `@better-auth/cli` +2. Create `lib/auth.ts` with server config +3. Create `lib/auth-client.ts` with React client +4. Set up route handler at `app/api/auth/[...all]/route.ts` +5. Configure Prisma adapter and generate schema +6. Add Google & GitHub OAuth providers +7. Enable `twoFactor` and `organization` plugins +8. Set up email verification handler +9. Run migrations +10. Create sign-in / sign-up pages +``` + +Ask the user to confirm the plan before proceeding to Phase 2. + +--- + +## Phase 2: Implementation + +Only proceed here after the user confirms the plan from Phase 1. + +Follow the decision tree below, guided by the answers collected above. + +``` +Is this a new/empty project? +├─ YES → New project setup +│ 1. Install better-auth (+ scoped packages per plan) +│ 2. Create auth.ts with all planned config +│ 3. Create auth-client.ts with framework client +│ 4. Set up route handler +│ 5. Set up environment variables +│ 6. Run CLI migrate/generate +│ 7. Add plugins from plan +│ 8. Create auth UI pages +│ +├─ MIGRATING → Migration from existing auth +│ 1. Audit current auth for gaps +│ 2. Plan incremental migration +│ 3. Install better-auth alongside existing auth +│ 4. Migrate routes, then session logic, then UI +│ 5. Remove old auth library +│ 6. See migration guides in docs +│ +└─ ADDING → Add auth to existing project + 1. Analyze project structure + 2. Install better-auth + 3. Create auth config matching plan + 4. Add route handler + 5. Run schema migrations + 6. Integrate into existing pages + 7. Add planned plugins and features +``` + +At the end of implementation, guide users thoroughly on remaining next steps (e.g., setting up OAuth app credentials, deploying env vars, testing flows). + +--- + +## Installation + +**Core:** `npm install better-auth` + +**Scoped packages (as needed):** +| Package | Use case | +|---------|----------| +| `@better-auth/passkey` | WebAuthn/Passkey auth | +| `@better-auth/sso` | SAML/OIDC enterprise SSO | +| `@better-auth/stripe` | Stripe payments | +| `@better-auth/scim` | SCIM user provisioning | +| `@better-auth/expo` | React Native/Expo | + +--- + +## Environment Variables + +```env +BETTER_AUTH_SECRET=<32+ chars, generate with: openssl rand -base64 32> +BETTER_AUTH_URL=http://localhost:3000 +DATABASE_URL= +``` + +Add OAuth secrets as needed: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, etc. + +--- + +## Server Config (auth.ts) + +**Location:** `lib/auth.ts` or `src/lib/auth.ts` + +**Minimal config needs:** +- `database` - Connection or adapter +- `emailAndPassword: { enabled: true }` - For email/password auth + +**Standard config adds:** +- `socialProviders` - OAuth providers (google, github, etc.) +- `emailVerification.sendVerificationEmail` - Email verification handler +- `emailAndPassword.sendResetPassword` - Password reset handler + +**Full config adds:** +- `plugins` - Array of feature plugins +- `session` - Expiry, cookie cache settings +- `account.accountLinking` - Multi-provider linking +- `rateLimit` - Rate limiting config + +**Export types:** `export type Session = typeof auth.$Infer.Session` + +--- + +## Client Config (auth-client.ts) + +**Import by framework:** +| Framework | Import | +|-----------|--------| +| React/Next.js | `better-auth/react` | +| Vue | `better-auth/vue` | +| Svelte | `better-auth/svelte` | +| Solid | `better-auth/solid` | +| Vanilla JS | `better-auth/client` | + +**Client plugins** go in `createAuthClient({ plugins: [...] })`. + +**Common exports:** `signIn`, `signUp`, `signOut`, `useSession`, `getSession` + +--- + +## Route Handler Setup + +| Framework | File | Handler | +|-----------|------|---------| +| Next.js App Router | `app/api/auth/[...all]/route.ts` | `toNextJsHandler(auth)` → export `{ GET, POST }` | +| Next.js Pages | `pages/api/auth/[...all].ts` | `toNextJsHandler(auth)` → default export | +| Express | Any file | `app.all("/api/auth/*", toNodeHandler(auth))` | +| SvelteKit | `src/hooks.server.ts` | `svelteKitHandler(auth)` | +| SolidStart | Route file | `solidStartHandler(auth)` | +| Hono | Route file | `auth.handler(c.req.raw)` | + +**Next.js Server Components:** Add `nextCookies()` plugin to auth config. + +--- + +## Database Migrations + +| Adapter | Command | +|---------|---------| +| Built-in Kysely | `npx @better-auth/cli@latest migrate` (applies directly) | +| Prisma | `npx @better-auth/cli@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev` | +| Drizzle | `npx @better-auth/cli@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` | + +**Re-run after adding plugins.** + +--- + +## Database Adapters + +| Database | Setup | +|----------|-------| +| SQLite | Pass `better-sqlite3` or `bun:sqlite` instance directly | +| PostgreSQL | Pass `pg.Pool` instance directly | +| MySQL | Pass `mysql2` pool directly | +| Prisma | `prismaAdapter(prisma, { provider: "postgresql" })` from `better-auth/adapters/prisma` | +| Drizzle | `drizzleAdapter(db, { provider: "pg" })` from `better-auth/adapters/drizzle` | +| MongoDB | `mongodbAdapter(db)` from `better-auth/adapters/mongodb` | + +--- + +## Common Plugins + +| Plugin | Server Import | Client Import | Purpose | +|--------|---------------|---------------|---------| +| `twoFactor` | `better-auth/plugins` | `twoFactorClient` | 2FA with TOTP/OTP | +| `organization` | `better-auth/plugins` | `organizationClient` | Teams/orgs | +| `admin` | `better-auth/plugins` | `adminClient` | User management | +| `bearer` | `better-auth/plugins` | - | API token auth | +| `openAPI` | `better-auth/plugins` | - | API docs | +| `passkey` | `@better-auth/passkey` | `passkeyClient` | WebAuthn | +| `sso` | `@better-auth/sso` | - | Enterprise SSO | + +**Plugin pattern:** Server plugin + client plugin + run migrations. + +--- + +## Auth UI Implementation + +**Sign in flow:** +1. `signIn.email({ email, password })` or `signIn.social({ provider, callbackURL })` +2. Handle `error` in response +3. Redirect on success + +**Session check (client):** `useSession()` hook returns `{ data: session, isPending }` + +**Session check (server):** `auth.api.getSession({ headers: await headers() })` + +**Protected routes:** Check session, redirect to `/sign-in` if null. + +--- + +## Security Checklist + +- [ ] `BETTER_AUTH_SECRET` set (32+ chars) +- [ ] `advanced.useSecureCookies: true` in production +- [ ] `trustedOrigins` configured +- [ ] Rate limits enabled +- [ ] Email verification enabled +- [ ] Password reset implemented +- [ ] 2FA for sensitive apps +- [ ] CSRF protection NOT disabled +- [ ] `account.accountLinking` reviewed + +--- + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| "Secret not set" | Add `BETTER_AUTH_SECRET` env var | +| "Invalid Origin" | Add domain to `trustedOrigins` | +| Cookies not setting | Check `baseURL` matches domain; enable secure cookies in prod | +| OAuth callback errors | Verify redirect URIs in provider dashboard | +| Type errors after adding plugin | Re-run CLI generate/migrate | + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Examples](https://github.com/better-auth/examples) +- [Plugins](https://better-auth.com/docs/concepts/plugins) +- [CLI](https://better-auth.com/docs/concepts/cli) +- [Migration Guides](https://better-auth.com/docs/guides) diff --git a/.agents/skills/email-and-password-best-practices/SKILL.md b/.agents/skills/email-and-password-best-practices/SKILL.md new file mode 100644 index 0000000..285f8a9 --- /dev/null +++ b/.agents/skills/email-and-password-best-practices/SKILL.md @@ -0,0 +1,224 @@ +--- +name: email-and-password-best-practices +description: This skill provides guidance and enforcement rules for implementing secure email and password authentication using Better Auth. +--- + +## Email Verification Setup + +When enabling email/password authentication, configure `emailVerification.sendVerificationEmail` to verify user email addresses. This helps prevent fake sign-ups and ensures users have access to the email they registered with. + +```ts +import { betterAuth } from "better-auth"; +import { sendEmail } from "./email"; // your email sending function + +export const auth = betterAuth({ + emailVerification: { + sendVerificationEmail: async ({ user, url, token }, request) => { + await sendEmail({ + to: user.email, + subject: "Verify your email address", + text: `Click the link to verify your email: ${url}`, + }); + }, + }, +}); +``` + +**Note**: The `url` parameter contains the full verification link. The `token` is available if you need to build a custom verification URL. + +### Requiring Email Verification + +For stricter security, enable `emailAndPassword.requireEmailVerification` to block sign-in until the user verifies their email. When enabled, unverified users will receive a new verification email on each sign-in attempt. + +```ts +export const auth = betterAuth({ + emailAndPassword: { + requireEmailVerification: true, + }, +}); +``` + +**Note**: This requires `sendVerificationEmail` to be configured and only applies to email/password sign-ins. + +## Client side validation + +While Better Auth validates inputs server-side, implementing client-side validation is still recommended for two key reasons: + +1. **Improved UX**: Users receive immediate feedback when inputs don't meet requirements, rather than waiting for a server round-trip. +2. **Reduced server load**: Invalid requests are caught early, minimizing unnecessary network traffic to your auth server. + +## Callback URLs + +Always use absolute URLs (including the origin) for callback URLs in sign-up and sign-in requests. This prevents Better Auth from needing to infer the origin, which can cause issues when your backend and frontend are on different domains. + +```ts +const { data, error } = await authClient.signUp.email({ + callbackURL: "https://example.com/callback", // absolute URL with origin +}); +``` + +## Password Reset Flows + +Password reset flows are essential to any email/password system, we recommend setting this up. + +To allow users to reset a password first you need to provide `sendResetPassword` function to the email and password authenticator. + +```ts +import { betterAuth } from "better-auth"; +import { sendEmail } from "./email"; // your email sending function + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + // Custom email sending function to send reset-password email + sendResetPassword: async ({ user, url, token }, request) => { + void sendEmail({ + to: user.email, + subject: "Reset your password", + text: `Click the link to reset your password: ${url}`, + }); + }, + // Optional event hook + onPasswordReset: async ({ user }, request) => { + // your logic here + console.log(`Password for user ${user.email} has been reset.`); + }, + }, +}); +``` + +### Security considerations + +Better Auth implements several security measures in the password reset flow: + +#### Timing attack prevention + +- **Background email sending**: Better Auth uses `runInBackgroundOrAwait` internally to send reset emails without blocking the response. This prevents attackers from measuring response times to determine if an email exists. +- **Dummy operations on invalid requests**: When a user is not found, Better Auth still performs token generation and a database lookup (with a dummy value) to maintain consistent response times. +- **Constant response message**: The API always returns `"If this email exists in our system, check your email for the reset link"` regardless of whether the user exists. + +On serverless platforms, configure a background task handler to ensure emails are sent reliably: + +```ts +export const auth = betterAuth({ + advanced: { + backgroundTasks: { + handler: (promise) => { + // Use platform-specific methods like waitUntil + waitUntil(promise); + }, + }, + }, +}); +``` + +#### Token security + +- **Cryptographically random tokens**: Reset tokens are generated using `generateId(24)`, producing a 24-character alphanumeric string (a-z, A-Z, 0-9) with high entropy. +- **Token expiration**: Tokens expire after **1 hour** by default. Configure with `resetPasswordTokenExpiresIn` (in seconds): + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes + }, +}); +``` + +- **Single-use tokens**: Tokens are deleted immediately after successful password reset, preventing reuse. + +#### Session revocation + +Enable `revokeSessionsOnPasswordReset` to invalidate all existing sessions when a password is reset. This ensures that if an attacker has an active session, it will be terminated: + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + revokeSessionsOnPasswordReset: true, + }, +}); +``` + +#### Redirect URL validation + +The `redirectTo` parameter is validated against your `trustedOrigins` configuration to prevent open redirect attacks. Malicious redirect URLs will be rejected with a 403 error. + +#### Password requirements + +During password reset, the new password must meet length requirements: +- **Minimum**: 8 characters (default), configurable via `minPasswordLength` +- **Maximum**: 128 characters (default), configurable via `maxPasswordLength` + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + minPasswordLength: 12, + maxPasswordLength: 256, + }, +}); +``` + +### Sending the password reset + +Once the password reset configurations are set-up, you can now call the `requestPasswordReset` function to send reset password link to user. If the user exists, it will trigger the `sendResetPassword` function you provided in the auth config. + +```ts +const data = await auth.api.requestPasswordReset({ + body: { + email: "john.doe@example.com", // required + redirectTo: "https://example.com/reset-password", + }, +}); +``` + +Or authClient: + +```ts +const { data, error } = await authClient.requestPasswordReset({ + email: "john.doe@example.com", // required + redirectTo: "https://example.com/reset-password", +}); +``` + +**Note**: While the `email` is required, we also recommend configuring the `redirectTo` for a smoother user experience. + +## Password Hashing + +Better Auth uses `scrypt` by default for password hashing. This is a solid choice because: + +- It's designed to be slow and memory-intensive, making brute-force attacks costly +- It's natively supported by Node.js (no external dependencies) +- OWASP recommends it when Argon2id isn't available + +### Custom Hashing Algorithm + +To use a different algorithm (e.g., Argon2id), provide custom `hash` and `verify` functions in the `emailAndPassword.password` configuration: + +```ts +import { betterAuth } from "better-auth"; +import { hash, verify, type Options } from "@node-rs/argon2"; + +const argon2Options: Options = { + memoryCost: 65536, // 64 MiB + timeCost: 3, // 3 iterations + parallelism: 4, // 4 parallel lanes + outputLen: 32, // 32 byte output + algorithm: 2, // Argon2id variant +}; + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + password: { + hash: (password) => hash(password, argon2Options), + verify: ({ password, hash: storedHash }) => + verify(storedHash, password, argon2Options), + }, + }, +}); +``` + +**Note**: If you switch hashing algorithms on an existing system, users with passwords hashed using the old algorithm won't be able to sign in. Plan a migration strategy if needed. diff --git a/.agents/skills/organization-best-practices/SKILL.md b/.agents/skills/organization-best-practices/SKILL.md new file mode 100644 index 0000000..c032018 --- /dev/null +++ b/.agents/skills/organization-best-practices/SKILL.md @@ -0,0 +1,586 @@ +--- +name: organization-best-practices +description: This skill provides guidance and enforcement rules for implementing multi-tenant organizations, teams, and role-based access control using Better Auth's organization plugin. +--- + +## Setting Up Organizations + +When adding organizations to your application, configure the `organization` plugin with appropriate limits and permissions. + +```ts +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + organization({ + allowUserToCreateOrganization: true, + organizationLimit: 5, // Max orgs per user + membershipLimit: 100, // Max members per org + }), + ], +}); +``` + +**Note**: After adding the plugin, run `npx @better-auth/cli migrate` to add the required database tables. + +### Client-Side Setup + +Add the client plugin to access organization methods: + +```ts +import { createAuthClient } from "better-auth/client"; +import { organizationClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [organizationClient()], +}); +``` + +## Creating Organizations + +Organizations are the top-level entity for grouping users. When created, the creator is automatically assigned the `owner` role. + +```ts +const createOrg = async () => { + const { data, error } = await authClient.organization.create({ + name: "My Company", + slug: "my-company", + logo: "https://example.com/logo.png", + metadata: { plan: "pro" }, + }); +}; +``` + +### Controlling Organization Creation + +Restrict who can create organizations based on user attributes: + +```ts +organization({ + allowUserToCreateOrganization: async (user) => { + return user.emailVerified === true; + }, + organizationLimit: async (user) => { + // Premium users get more organizations + return user.plan === "premium" ? 20 : 3; + }, +}); +``` + +### Creating Organizations on Behalf of Users + +Administrators can create organizations for other users (server-side only): + +```ts +await auth.api.createOrganization({ + body: { + name: "Client Organization", + slug: "client-org", + userId: "user-id-who-will-be-owner", // `userId` is required + }, +}); +``` + +**Note**: The `userId` parameter cannot be used alongside session headers. + + +## Active Organizations + +The active organization is stored in the session and scopes subsequent API calls. Always set an active organization after the user selects one. + +```ts +const setActive = async (organizationId: string) => { + const { data, error } = await authClient.organization.setActive({ + organizationId, + }); +}; +``` + +Many endpoints use the active organization when `organizationId` is not provided: + +```ts +// These use the active organization automatically +await authClient.organization.listMembers(); +await authClient.organization.listInvitations(); +await authClient.organization.inviteMember({ email: "user@example.com", role: "member" }); +``` + +### Getting Full Organization Data + +Retrieve the active organization with all its members, invitations, and teams: + +```ts +const { data } = await authClient.organization.getFullOrganization(); +// data.organization, data.members, data.invitations, data.teams +``` + +## Members + +Members are users who belong to an organization. Each member has a role that determines their permissions. + +### Adding Members (Server-Side) + +Add members directly without invitations (useful for admin operations): + +```ts +await auth.api.addMember({ + body: { + userId: "user-id", + role: "member", + organizationId: "org-id", + }, +}); +``` + +**Note**: For client-side member additions, use the invitation system instead. + +### Assigning Multiple Roles + +Members can have multiple roles for fine-grained permissions: + +```ts +await auth.api.addMember({ + body: { + userId: "user-id", + role: ["admin", "moderator"], + organizationId: "org-id", + }, +}); +``` + +### Removing Members + +Remove members by ID or email: + +```ts +await authClient.organization.removeMember({ + memberIdOrEmail: "user@example.com", +}); +``` + +**Important**: The last owner cannot be removed. Assign the owner role to another member first. + +### Updating Member Roles + +```ts +await authClient.organization.updateMemberRole({ + memberId: "member-id", + role: "admin", +}); +``` + +### Membership Limits + +Control the maximum number of members per organization: + +```ts +organization({ + membershipLimit: async (user, organization) => { + if (organization.metadata?.plan === "enterprise") { + return 1000; + } + return 50; + }, +}); +``` + +## Invitations + +The invitation system allows admins to invite users via email. Configure email sending to enable invitations. + +### Setting Up Invitation Emails + +```ts +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import { sendEmail } from "./email"; + +export const auth = betterAuth({ + plugins: [ + organization({ + sendInvitationEmail: async (data) => { + const { email, organization, inviter, invitation } = data; + + await sendEmail({ + to: email, + subject: `Join ${organization.name}`, + html: ` +

${inviter.user.name} invited you to join ${organization.name}

+ + Accept Invitation + + `, + }); + }, + }), + ], +}); +``` + +### Sending Invitations + +```ts +await authClient.organization.inviteMember({ + email: "newuser@example.com", + role: "member", +}); +``` + +### Creating Shareable Invitation URLs + +For sharing via Slack, SMS, or in-app notifications: + +```ts +const { data } = await authClient.organization.getInvitationURL({ + email: "newuser@example.com", + role: "member", + callbackURL: "https://yourapp.com/dashboard", +}); + +// Share data.url via any channel +``` + +**Note**: This endpoint does not call `sendInvitationEmail`. Handle delivery yourself. + +### Accepting Invitations + +```ts +await authClient.organization.acceptInvitation({ + invitationId: "invitation-id", +}); +``` + +### Invitation Configuration + +```ts +organization({ + invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days (default: 48 hours) + invitationLimit: 100, // Max pending invitations per org + cancelPendingInvitationsOnReInvite: true, // Cancel old invites when re-inviting +}); +``` + +## Roles & Permissions + +The plugin provides role-based access control (RBAC) with three default roles: + +| Role | Description | +|------|-------------| +| `owner` | Full access, can delete organization | +| `admin` | Can manage members, invitations, settings | +| `member` | Basic access to organization resources | + + +### Checking Permissions + +```ts +const { data } = await authClient.organization.hasPermission({ + permission: "member:write", +}); + +if (data?.hasPermission) { + // User can manage members +} +``` + +### Client-Side Permission Checks + +For UI rendering without API calls: + +```ts +const canManageMembers = authClient.organization.checkRolePermission({ + role: "admin", + permissions: ["member:write"], +}); +``` + +**Note**: For dynamic access control, the client side role permission check will not work. Please use the `hasPermission` endpoint. + +## Teams + +Teams allow grouping members within an organization. + +### Enabling Teams + +```ts +import { organization } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + organization({ + teams: { + enabled: true + } + }), + ], +}); +``` + +### Creating Teams + +```ts +const { data } = await authClient.organization.createTeam({ + name: "Engineering", +}); +``` + +### Managing Team Members + +```ts +// Add a member to a team (must be org member first) +await authClient.organization.addTeamMember({ + teamId: "team-id", + userId: "user-id", +}); + +// Remove from team (stays in org) +await authClient.organization.removeTeamMember({ + teamId: "team-id", + userId: "user-id", +}); +``` + +### Active Teams + +Similar to active organizations, set an active team for the session: + +```ts +await authClient.organization.setActiveTeam({ + teamId: "team-id", +}); +``` + +### Team Limits + +```ts +organization({ + teams: { + maximumTeams: 20, // Max teams per org + maximumMembersPerTeam: 50, // Max members per team + allowRemovingAllTeams: false, // Prevent removing last team + } +}); +``` + +## Dynamic Access Control + +For applications needing custom roles per organization at runtime, enable dynamic access control. + +### Enabling Dynamic Access Control + +```ts +import { organization } from "better-auth/plugins"; +import { dynamicAccessControl } from "@better-auth/organization/addons"; + +export const auth = betterAuth({ + plugins: [ + organization({ + dynamicAccessControl: { + enabled: true + } + }), + ], +}); +``` + +### Creating Custom Roles + +```ts +await authClient.organization.createRole({ + role: "moderator", + permission: { + member: ["read"], + invitation: ["read"], + }, +}); +``` + +### Updating and Deleting Roles + +```ts +// Update role permissions +await authClient.organization.updateRole({ + roleId: "role-id", + permission: { + member: ["read", "write"], + }, +}); + +// Delete a custom role +await authClient.organization.deleteRole({ + roleId: "role-id", +}); +``` + +**Note**: Pre-defined roles (owner, admin, member) cannot be deleted. Roles assigned to members cannot be deleted until members are reassigned. + +## Lifecycle Hooks + +Execute custom logic at various points in the organization lifecycle: + +```ts +organization({ + hooks: { + organization: { + beforeCreate: async ({ data, user }) => { + // Validate or modify data before creation + return { + data: { + ...data, + metadata: { ...data.metadata, createdBy: user.id }, + }, + }; + }, + afterCreate: async ({ organization, member }) => { + // Post-creation logic (e.g., send welcome email, create default resources) + await createDefaultResources(organization.id); + }, + beforeDelete: async ({ organization }) => { + // Cleanup before deletion + await archiveOrganizationData(organization.id); + }, + }, + member: { + afterCreate: async ({ member, organization }) => { + await notifyAdmins(organization.id, `New member joined`); + }, + }, + invitation: { + afterCreate: async ({ invitation, organization, inviter }) => { + await logInvitation(invitation); + }, + }, + }, +}); +``` + +## Schema Customization + +Customize table names, field names, and add additional fields: + +```ts +organization({ + schema: { + organization: { + modelName: "workspace", // Rename table + fields: { + name: "workspaceName", // Rename fields + }, + additionalFields: { + billingId: { + type: "string", + required: false, + }, + }, + }, + member: { + additionalFields: { + department: { + type: "string", + required: false, + }, + title: { + type: "string", + required: false, + }, + }, + }, + }, +}); +``` + +## Security Considerations + +### Owner Protection + +- The last owner cannot be removed from an organization +- The last owner cannot leave the organization +- The owner role cannot be removed from the last owner + +Always ensure ownership transfer before removing the current owner: + +```ts +// Transfer ownership first +await authClient.organization.updateMemberRole({ + memberId: "new-owner-member-id", + role: "owner", +}); + +// Then the previous owner can be demoted or removed +``` + +### Organization Deletion + +Deleting an organization removes all associated data (members, invitations, teams). Prevent accidental deletion: + +```ts +organization({ + disableOrganizationDeletion: true, // Disable via config +}); +``` + +Or implement soft delete via hooks: + +```ts +organization({ + hooks: { + organization: { + beforeDelete: async ({ organization }) => { + // Archive instead of delete + await archiveOrganization(organization.id); + throw new Error("Organization archived, not deleted"); + }, + }, + }, +}); +``` + +### Invitation Security + +- Invitations expire after 48 hours by default +- Only the invited email address can accept an invitation +- Pending invitations can be cancelled by organization admins + +## Complete Configuration Example + +```ts +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import { sendEmail } from "./email"; + +export const auth = betterAuth({ + plugins: [ + organization({ + // Organization limits + allowUserToCreateOrganization: true, + organizationLimit: 10, + membershipLimit: 100, + creatorRole: "owner", + + // Slugs + defaultOrganizationIdField: "slug", + + // Invitations + invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days + invitationLimit: 50, + sendInvitationEmail: async (data) => { + await sendEmail({ + to: data.email, + subject: `Join ${data.organization.name}`, + html: `Accept`, + }); + }, + + // Hooks + hooks: { + organization: { + afterCreate: async ({ organization }) => { + console.log(`Organization ${organization.name} created`); + }, + }, + }, + }), + ], +}); +``` diff --git a/.agents/skills/two-factor-authentication-best-practices/SKILL.md b/.agents/skills/two-factor-authentication-best-practices/SKILL.md new file mode 100644 index 0000000..d44f9a3 --- /dev/null +++ b/.agents/skills/two-factor-authentication-best-practices/SKILL.md @@ -0,0 +1,417 @@ +--- +name: two-factor-authentication-best-practices +description: This skill provides guidance and enforcement rules for implementing secure two-factor authentication (2FA) using Better Auth's twoFactor plugin. +--- + +## Setting Up Two-Factor Authentication + +When adding 2FA to your application, configure the `twoFactor` plugin with your app name as the issuer. This name appears in authenticator apps when users scan the QR code. + +```ts +import { betterAuth } from "better-auth"; +import { twoFactor } from "better-auth/plugins"; + +export const auth = betterAuth({ + appName: "My App", // Used as the default issuer for TOTP + plugins: [ + twoFactor({ + issuer: "My App", // Optional: override the app name for 2FA specifically + }), + ], +}); +``` + +**Note**: After adding the plugin, run `npx @better-auth/cli migrate` to add the required database fields and tables. + +### Client-Side Setup + +Add the client plugin and configure the redirect behavior for 2FA verification: + +```ts +import { createAuthClient } from "better-auth/client"; +import { twoFactorClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [ + twoFactorClient({ + onTwoFactorRedirect() { + window.location.href = "/2fa"; // Redirect to your 2FA verification page + }, + }), + ], +}); +``` + +## Enabling 2FA for Users + +When a user enables 2FA, require their password for verification. The enable endpoint returns a TOTP URI for QR code generation and backup codes for account recovery. + +```ts +const enable2FA = async (password: string) => { + const { data, error } = await authClient.twoFactor.enable({ + password, + }); + + if (data) { + // data.totpURI - Use this to generate a QR code + // data.backupCodes - Display these to the user for safekeeping + } +}; +``` + +**Important**: The `twoFactorEnabled` flag on the user is not set to `true` until the user successfully verifies their first TOTP code. This ensures users have properly configured their authenticator app before 2FA is fully active. + +### Skipping Initial Verification + +If you want to enable 2FA immediately without requiring verification, set `skipVerificationOnEnable`: + +```ts +twoFactor({ + skipVerificationOnEnable: true, // Not recommended for most use cases +}); +``` + +**Note**: This is generally not recommended as it doesn't confirm the user has successfully set up their authenticator app. + +## TOTP (Authenticator App) + +TOTP generates time-based codes using an authenticator app (Google Authenticator, Authy, etc.). Codes are valid for 30 seconds by default. + +### Displaying the QR Code + +Use the TOTP URI to generate a QR code for users to scan: + +```tsx +import QRCode from "react-qr-code"; + +const TotpSetup = ({ totpURI }: { totpURI: string }) => { + return ; +}; +``` + +### Verifying TOTP Codes + +Better Auth accepts codes from one period before and one after the current time, accommodating minor clock differences between devices: + +```ts +const verifyTotp = async (code: string) => { + const { data, error } = await authClient.twoFactor.verifyTotp({ + code, + trustDevice: true, // Optional: remember this device for 30 days + }); +}; +``` + +### TOTP Configuration Options + +```ts +twoFactor({ + totpOptions: { + digits: 6, // 6 or 8 digits (default: 6) + period: 30, // Code validity period in seconds (default: 30) + }, +}); +``` + +## OTP (Email/SMS) + +OTP sends a one-time code to the user's email or phone. You must implement the `sendOTP` function to deliver codes. + +### Configuring OTP Delivery + +```ts +import { betterAuth } from "better-auth"; +import { twoFactor } from "better-auth/plugins"; +import { sendEmail } from "./email"; + +export const auth = betterAuth({ + plugins: [ + twoFactor({ + otpOptions: { + sendOTP: async ({ user, otp }, ctx) => { + await sendEmail({ + to: user.email, + subject: "Your verification code", + text: `Your code is: ${otp}`, + }); + }, + period: 5, // Code validity in minutes (default: 3) + digits: 6, // Number of digits (default: 6) + allowedAttempts: 5, // Max verification attempts (default: 5) + }, + }), + ], +}); +``` + +### Sending and Verifying OTP + +```ts +// Request an OTP to be sent +const sendOtp = async () => { + const { data, error } = await authClient.twoFactor.sendOtp(); +}; + +// Verify the OTP code +const verifyOtp = async (code: string) => { + const { data, error } = await authClient.twoFactor.verifyOtp({ + code, + trustDevice: true, + }); +}; +``` + +### OTP Storage Security + +Configure how OTP codes are stored in the database: + +```ts +twoFactor({ + otpOptions: { + storeOTP: "encrypted", // Options: "plain", "encrypted", "hashed" + }, +}); +``` + +For custom encryption: + +```ts +twoFactor({ + otpOptions: { + storeOTP: { + encrypt: async (token) => myEncrypt(token), + decrypt: async (token) => myDecrypt(token), + }, + }, +}); +``` + +## Backup Codes + +Backup codes provide account recovery when users lose access to their authenticator app or phone. They are generated automatically when 2FA is enabled. + +### Displaying Backup Codes + +Always show backup codes to users when they enable 2FA: + +```tsx +const BackupCodes = ({ codes }: { codes: string[] }) => { + return ( +
+

Save these codes in a secure location:

+
    + {codes.map((code, i) => ( +
  • {code}
  • + ))} +
+
+ ); +}; +``` + +### Regenerating Backup Codes + +When users need new codes, regenerate them (this invalidates all previous codes): + +```ts +const regenerateBackupCodes = async (password: string) => { + const { data, error } = await authClient.twoFactor.generateBackupCodes({ + password, + }); + // data.backupCodes contains the new codes +}; +``` + +### Using Backup Codes for Recovery + +```ts +const verifyBackupCode = async (code: string) => { + const { data, error } = await authClient.twoFactor.verifyBackupCode({ + code, + trustDevice: true, + }); +}; +``` + +**Note**: Each backup code can only be used once and is removed from the database after successful verification. + +### Backup Code Configuration + +```ts +twoFactor({ + backupCodeOptions: { + amount: 10, // Number of codes to generate (default: 10) + length: 10, // Length of each code (default: 10) + storeBackupCodes: "encrypted", // Options: "plain", "encrypted" + }, +}); +``` + +## Handling 2FA During Sign-In + +When a user with 2FA enabled signs in, the response includes `twoFactorRedirect: true`: + +```ts +const signIn = async (email: string, password: string) => { + const { data, error } = await authClient.signIn.email( + { + email, + password, + }, + { + onSuccess(context) { + if (context.data.twoFactorRedirect) { + // Redirect to 2FA verification page + window.location.href = "/2fa"; + } + }, + } + ); +}; +``` + +### Server-Side 2FA Detection + +When using `auth.api.signInEmail` on the server, check for 2FA redirect: + +```ts +const response = await auth.api.signInEmail({ + body: { + email: "user@example.com", + password: "password", + }, +}); + +if ("twoFactorRedirect" in response) { + // Handle 2FA verification +} +``` + +## Trusted Devices + +Trusted devices allow users to skip 2FA verification on subsequent sign-ins for a configurable period. + +### Enabling Trust on Verification + +Pass `trustDevice: true` when verifying 2FA: + +```ts +await authClient.twoFactor.verifyTotp({ + code: "123456", + trustDevice: true, +}); +``` + +### Configuring Trust Duration + +```ts +twoFactor({ + trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days in seconds (default) +}); +``` + +**Note**: The trust period refreshes on each successful sign-in within the trust window. + +## Security Considerations + +### Session Management + +During the 2FA flow: + +1. User signs in with credentials +2. Session cookie is removed (not yet authenticated) +3. A temporary two-factor cookie is set (default: 10-minute expiration) +4. User verifies via TOTP, OTP, or backup code +5. Session cookie is created upon successful verification + +Configure the two-factor cookie expiration: + +```ts +twoFactor({ + twoFactorCookieMaxAge: 600, // 10 minutes in seconds (default) +}); +``` + +### Rate Limiting + +Better Auth applies built-in rate limiting to all 2FA endpoints (3 requests per 10 seconds). For OTP verification, additional attempt limiting is applied: + +```ts +twoFactor({ + otpOptions: { + allowedAttempts: 5, // Max attempts per OTP code (default: 5) + }, +}); +``` + +### Encryption at Rest + +- TOTP secrets are encrypted using symmetric encryption with your auth secret +- Backup codes are stored encrypted by default +- OTP codes can be configured for plain, encrypted, or hashed storage + +### Constant-Time Comparison + +Better Auth uses constant-time comparison for OTP verification to prevent timing attacks. + +### Credential Account Requirement + +Two-factor authentication can only be enabled for credential (email/password) accounts. For social accounts, it's assumed the provider already handles 2FA. + +## Disabling 2FA + +Allow users to disable 2FA with password confirmation: + +```ts +const disable2FA = async (password: string) => { + const { data, error } = await authClient.twoFactor.disable({ + password, + }); +}; +``` + +**Note**: When 2FA is disabled, trusted device records are revoked. + +## Complete Configuration Example + +```ts +import { betterAuth } from "better-auth"; +import { twoFactor } from "better-auth/plugins"; +import { sendEmail } from "./email"; + +export const auth = betterAuth({ + appName: "My App", + plugins: [ + twoFactor({ + // TOTP settings + issuer: "My App", + totpOptions: { + digits: 6, + period: 30, + }, + // OTP settings + otpOptions: { + sendOTP: async ({ user, otp }) => { + await sendEmail({ + to: user.email, + subject: "Your verification code", + text: `Your code is: ${otp}`, + }); + }, + period: 5, + allowedAttempts: 5, + storeOTP: "encrypted", + }, + // Backup code settings + backupCodeOptions: { + amount: 10, + length: 10, + storeBackupCodes: "encrypted", + }, + // Session settings + twoFactorCookieMaxAge: 600, // 10 minutes + trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days + }), + ], +}); +``` diff --git a/.claude/skills/better-auth-best-practices b/.claude/skills/better-auth-best-practices new file mode 120000 index 0000000..d28d6bd --- /dev/null +++ b/.claude/skills/better-auth-best-practices @@ -0,0 +1 @@ +../../.agents/skills/better-auth-best-practices \ No newline at end of file diff --git a/.claude/skills/better-auth-security-best-practices b/.claude/skills/better-auth-security-best-practices new file mode 120000 index 0000000..a534020 --- /dev/null +++ b/.claude/skills/better-auth-security-best-practices @@ -0,0 +1 @@ +../../.agents/skills/better-auth-security-best-practices \ No newline at end of file diff --git a/.claude/skills/create-auth-skill b/.claude/skills/create-auth-skill new file mode 120000 index 0000000..97515d2 --- /dev/null +++ b/.claude/skills/create-auth-skill @@ -0,0 +1 @@ +../../.agents/skills/create-auth-skill \ No newline at end of file diff --git a/.claude/skills/email-and-password-best-practices b/.claude/skills/email-and-password-best-practices new file mode 120000 index 0000000..e78bcf6 --- /dev/null +++ b/.claude/skills/email-and-password-best-practices @@ -0,0 +1 @@ +../../.agents/skills/email-and-password-best-practices \ No newline at end of file diff --git a/.claude/skills/organization-best-practices b/.claude/skills/organization-best-practices new file mode 120000 index 0000000..d43ca82 --- /dev/null +++ b/.claude/skills/organization-best-practices @@ -0,0 +1 @@ +../../.agents/skills/organization-best-practices \ No newline at end of file diff --git a/.claude/skills/two-factor-authentication-best-practices b/.claude/skills/two-factor-authentication-best-practices new file mode 120000 index 0000000..0defcaa --- /dev/null +++ b/.claude/skills/two-factor-authentication-best-practices @@ -0,0 +1 @@ +../../.agents/skills/two-factor-authentication-best-practices \ No newline at end of file diff --git a/.env.example b/.env.example index 23e2c97..3f10473 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,12 @@ POSTHOG_CLI_ENV_ID=<***> # Personal API key with error tracking write and organization read scopes # See https://app.posthog.com/settings/user-api-keys#variables POSTHOG_CLI_TOKEN=<***> + +# Auth - Better Auth +# Generate the secret using `openssl rand -base64 32`. +AUTH_SECRET=<***> +AUTH_GITHUB_CLIENT_ID=<***> +AUTH_GITHUB_CLIENT_SECRET=<***> +AUTH_GOOGLE_CLIENT_ID=<***> +AUTH_GOOGLE_CLIENT_SECRET=<***> + diff --git a/.github/workflows/deployment-preview.yaml b/.github/workflows/deployment-preview.yaml index 05be620..d49b48f 100644 --- a/.github/workflows/deployment-preview.yaml +++ b/.github/workflows/deployment-preview.yaml @@ -61,3 +61,9 @@ jobs: POSTHOG_CLI_HOST: ${{ vars.POSTHOG_CLI_HOST }} POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }} POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }} + # Auth + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_GITHUB_CLIENT_ID: ${{ secrets.AUTH_GITHUB_CLIENT_ID }} + AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }} + AUTH_GOOGLE_CLIENT_ID: ${{ secrets.AUTH_GOOGLE_CLIENT_ID }} + AUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.AUTH_GOOGLE_CLIENT_SECRET }} diff --git a/.github/workflows/deployment-production.yaml b/.github/workflows/deployment-production.yaml index d0492ed..6e852c7 100644 --- a/.github/workflows/deployment-production.yaml +++ b/.github/workflows/deployment-production.yaml @@ -52,3 +52,9 @@ jobs: POSTHOG_CLI_HOST: ${{ vars.POSTHOG_CLI_HOST }} POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_CLI_ENV_ID }} POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }} + # Auth + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_GITHUB_CLIENT_ID: ${{ secrets.AUTH_GITHUB_CLIENT_ID }} + AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }} + AUTH_GOOGLE_CLIENT_ID: ${{ secrets.AUTH_GOOGLE_CLIENT_ID }} + AUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.AUTH_GOOGLE_CLIENT_SECRET }} diff --git a/alchemy.run.ts b/alchemy.run.ts index 2c69515..6633ab5 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -11,15 +11,25 @@ */ import alchemy, { type Scope } from 'alchemy'; -import { D1Database, TanStackStart } from 'alchemy/cloudflare'; +import { D1Database, KVNamespace, TanStackStart } from 'alchemy/cloudflare'; import { GitHubComment } from 'alchemy/github'; import { CloudflareStateStore, FileSystemStateStore } from 'alchemy/state'; import packageJson from './package.json' with { type: 'json' }; import { alchemyEnv } from './src/lib/env/alchemy.ts'; +import { serverEnv } from './src/lib/env/server.ts'; const ALCHEMY_SECRET = alchemyEnv.ALCHEMY_SECRET; const ALCHEMY_STATE_TOKEN = alchemy.secret(alchemyEnv.ALCHEMY_STATE_TOKEN); +const AUTH_SECRET = alchemy.secret(serverEnv.AUTH_SECRET); +const AUTH_GITHUB_CLIENT_ID = alchemy.secret(serverEnv.AUTH_GITHUB_CLIENT_ID); +const AUTH_GITHUB_CLIENT_SECRET = alchemy.secret( + serverEnv.AUTH_GITHUB_CLIENT_SECRET, +); +const AUTH_GOOGLE_CLIENT_ID = alchemy.secret(serverEnv.AUTH_GOOGLE_CLIENT_ID); +const AUTH_GOOGLE_CLIENT_SECRET = alchemy.secret( + serverEnv.AUTH_GOOGLE_CLIENT_SECRET, +); function isProductionStage(scope: Scope) { return scope.stage === 'production'; @@ -50,6 +60,10 @@ const database = await D1Database('database', { }, }); +const kv = await KVNamespace('kv', { + adopt: true, +}); + export const worker = await TanStackStart('website', { adopt: true, observability: isProductionStage(app) ? { enabled: true } : undefined, @@ -57,7 +71,15 @@ export const worker = await TanStackStart('website', { domains: isProductionStage(app) ? [alchemyEnv.HOSTNAME] : undefined, placement: isProductionStage(app) ? { mode: 'smart' } : undefined, bindings: { + // Services DATABASE: database, + KV: kv, + // Environment variables + AUTH_SECRET: AUTH_SECRET, + AUTH_GITHUB_CLIENT_ID: AUTH_GITHUB_CLIENT_ID, + AUTH_GITHUB_CLIENT_SECRET: AUTH_GITHUB_CLIENT_SECRET, + AUTH_GOOGLE_CLIENT_ID: AUTH_GOOGLE_CLIENT_ID, + AUTH_GOOGLE_CLIENT_SECRET: AUTH_GOOGLE_CLIENT_SECRET, }, }); diff --git a/messages/en.json b/messages/en.json index 8128340..5234171 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,5 +5,97 @@ "common_error_something_went_wrong": "Something went wrong", "common_error_form_validation_title": "There is something wrong with the form", - "common_error_form_validation_description": "Please review the form and correct them to continue." + "common_error_form_validation_description": "Please review the form and correct them to continue.", + + "common_greeting_name": "Hi, {name}!", + "common_continue_as_name": "Continue as {name}", + + "auth_get_started_title": "Let's Get You Started", + "auth_get_started_description": "Choose how you'd like to continue", + "auth_get_started_action": "Get Started", + "auth_get_started_signup_title": "Create Your Account", + "auth_get_started_signup_description": "New here? Sign up to create your account and start your journey with us.", + "auth_get_started_signin_title": "Existing Account", + "auth_get_started_signin_description": "Already have an account? Sign in to pick up right where you left off.", + + "auth_continue_with_social_provider": "Continue with {provider}", + "auth_or_separator": "Or", + + "auth_field_full_name_label": "Full name", + "auth_field_full_name_placeholder": "Devsantara Team", + "auth_field_email_label": "Email", + "auth_field_email_placeholder": "team@devsantara.com", + "auth_field_password_label": "Password", + "auth_field_confirm_password_label": "Confirm Password", + "auth_field_error_name_required": "Name is required", + "auth_field_error_email_invalid": "Please enter a valid email address", + "auth_field_error_password_min_length": "Password must be at least {min} characters", + "auth_field_error_confirm_password_not_match": "Password and confirmation password do not match", + + "auth_sign_up_title": "Create your account", + "auth_sign_up_description": "Fill out the form below to create your account", + "auth_sign_up_action": "Create Account", + "auth_sign_up_already_have_account": "Already have an account?", + "auth_sign_up_sign_in_link": "Sign in", + "auth_sign_up_error_fail": "Sign up failed", + "auth_sign_up_success_title": "Account created successfully", + "auth_sign_up_success_description": "Please sign in to continue.", + + "auth_sign_in_title": "Sign in to your account", + "auth_sign_in_description": "Please enter your details to continue.", + "auth_sign_in_action": "Sign in", + "auth_sign_in_fail": "Sign in failed", + "auth_sign_in_success_title": "Welcome back!", + "auth_sign_in_success_description": "We're happy to see you again.", + "auth_sign_in_no_account": "Don't have an account?", + "auth_sign_in_sign_up_link": "Sign up", + + "auth_sign_out_action": "Sign out", + "auth_sign_out_error_fail": "Sign out failed", + "auth_sign_out_success_title": "You've been signed out", + "auth_sign_out_success_description": "We hope to see you again soon.", + + "auth_error_base_user_not_found": "User not found", + "auth_error_base_failed_to_create_user": "Failed to create user", + "auth_error_base_failed_to_create_session": "Failed to create session", + "auth_error_base_failed_to_update_user": "Failed to update user", + "auth_error_base_failed_to_get_session": "Failed to get session", + "auth_error_base_invalid_password": "Invalid password", + "auth_error_base_invalid_email": "Invalid email", + "auth_error_base_invalid_email_or_password": "Invalid email or password", + "auth_error_base_social_account_already_linked": "Social account already linked", + "auth_error_base_provider_not_found": "Provider not found", + "auth_error_base_invalid_token": "Invalid token", + "auth_error_base_id_token_not_supported": "id_token not supported", + "auth_error_base_failed_to_get_user_info": "Failed to get user info", + "auth_error_base_user_email_not_found": "User email not found", + "auth_error_base_email_not_verified": "Email not verified", + "auth_error_base_password_too_short": "Password too short", + "auth_error_base_password_too_long": "Password too long", + "auth_error_base_user_already_exists": "User already exists.", + "auth_error_base_user_already_exists_use_another_email": "User already exists. Use another email.", + "auth_error_base_email_can_not_be_updated": "Email can not be updated", + "auth_error_base_credential_account_not_found": "Credential account not found", + "auth_error_base_session_expired": "Session expired. Re-authenticate to perform this action.", + "auth_error_base_failed_to_unlink_last_account": "You can't unlink your last account", + "auth_error_base_account_not_found": "Account not found", + "auth_error_base_user_already_has_password": "User already has a password. Provide that to delete the account.", + "auth_error_base_cross_site_navigation_login_blocked": "Cross-site navigation login blocked. This request appears to be a CSRF attack.", + "auth_error_base_verification_email_not_enabled": "Verification email isn't enabled", + "auth_error_base_email_already_verified": "Email is already verified", + "auth_error_base_email_mismatch": "Email mismatch", + "auth_error_base_session_not_fresh": "Session is not fresh", + "auth_error_base_linked_account_already_exists": "Linked account already exists", + "auth_error_base_invalid_origin": "Invalid origin", + "auth_error_base_invalid_callback_url": "Invalid callbackURL", + "auth_error_base_invalid_redirect_url": "Invalid redirectURL", + "auth_error_base_invalid_error_callback_url": "Invalid errorCallbackURL", + "auth_error_base_invalid_new_user_callback_url": "Invalid newUserCallbackURL", + "auth_error_base_missing_or_null_origin": "Missing or null Origin", + "auth_error_base_callback_url_required": "callbackURL is required", + "auth_error_base_failed_to_create_verification": "Unable to create verification", + "auth_error_base_field_not_allowed": "Field not allowed to be set", + "auth_error_base_async_validation_not_supported": "Async validation is not supported", + "auth_error_base_validation_error": "Validation Error", + "auth_error_base_missing_field": "Field is required" } diff --git a/messages/id.json b/messages/id.json index e8ec8b5..d55d30b 100644 --- a/messages/id.json +++ b/messages/id.json @@ -5,5 +5,97 @@ "common_error_something_went_wrong": "Terjadi kesalahan", "common_error_form_validation_title": "Ada kesalahan pada formulir", - "common_error_form_validation_description": "Silakan periksa kembali formulir dan perbaiki untuk melanjutkan." + "common_error_form_validation_description": "Silakan periksa kembali formulir dan perbaiki untuk melanjutkan.", + + "common_greeting_name": "Hai, {name}!", + "common_continue_as_name": "Lanjutkan sebagai {name}", + + "auth_get_started_title": "Mari Memulai", + "auth_get_started_description": "Pilih cara untuk melanjutkan", + "auth_get_started_action": "Mulai", + "auth_get_started_signup_title": "Buat Akun Anda", + "auth_get_started_signup_description": "Baru di sini? Daftar untuk membuat akun dan mulai perjalanan Anda bersama kami.", + "auth_get_started_signin_title": "Akun yang Ada", + "auth_get_started_signin_description": "Sudah punya akun? Masuk untuk melanjutkan dari terakhir kali Anda berhenti.", + + "auth_continue_with_social_provider": "Lanjutkan dengan {provider}", + "auth_or_separator": "Atau", + + "auth_field_full_name_label": "Nama lengkap", + "auth_field_full_name_placeholder": "Tim Devsantara", + "auth_field_email_label": "Email", + "auth_field_email_placeholder": "team@devsantara.com", + "auth_field_password_label": "Kata sandi", + "auth_field_confirm_password_label": "Konfirmasi Kata Sandi", + "auth_field_error_name_required": "Nama wajib diisi", + "auth_field_error_email_invalid": "Silakan masukkan alamat email yang valid", + "auth_field_error_password_min_length": "Password harus memiliki minimal {min} karakter", + "auth_field_error_confirm_password_not_match": "Password dan konfirmasi password Anda tidak sama", + + "auth_sign_up_title": "Buat akun Anda", + "auth_sign_up_description": "Isi formulir di bawah untuk membuat akun Anda", + "auth_sign_up_action": "Buat Akun", + "auth_sign_up_already_have_account": "Sudah punya akun?", + "auth_sign_up_sign_in_link": "Masuk", + "auth_sign_up_error_fail": "Pendaftaran gagal", + "auth_sign_up_success_title": "Akun berhasil dibuat", + "auth_sign_up_success_description": "Silakan masuk untuk melanjutkan.", + + "auth_sign_in_title": "Masuk ke akun Anda", + "auth_sign_in_description": "Silakan masukkan detail Anda untuk melanjutkan.", + "auth_sign_in_action": "Masuk", + "auth_sign_in_fail": "Gagal masuk", + "auth_sign_in_success_title": "Selamat datang kembali!", + "auth_sign_in_success_description": "Senang melihat Anda kembali.", + "auth_sign_in_no_account": "Belum punya akun?", + "auth_sign_in_sign_up_link": "Daftar", + + "auth_sign_out_action": "Keluar", + "auth_sign_out_error_fail": "Gagal keluar", + "auth_sign_out_success_title": "Berhasil keluar", + "auth_sign_out_success_description": "Sampai jumpa lagi.", + + "auth_error_base_user_not_found": "Pengguna tidak ditemukan", + "auth_error_base_failed_to_create_user": "Gagal membuat pengguna", + "auth_error_base_failed_to_create_session": "Gagal membuat sesi", + "auth_error_base_failed_to_update_user": "Gagal memperbarui pengguna", + "auth_error_base_failed_to_get_session": "Gagal mengambil sesi", + "auth_error_base_invalid_password": "Password tidak valid", + "auth_error_base_invalid_email": "Email tidak valid", + "auth_error_base_invalid_email_or_password": "Email atau password tidak valid", + "auth_error_base_social_account_already_linked": "Akun sosial sudah tertaut", + "auth_error_base_provider_not_found": "Provider tidak ditemukan", + "auth_error_base_invalid_token": "Token tidak valid", + "auth_error_base_id_token_not_supported": "id_token tidak didukung", + "auth_error_base_failed_to_get_user_info": "Gagal mengambil info pengguna", + "auth_error_base_user_email_not_found": "Email pengguna tidak ditemukan", + "auth_error_base_email_not_verified": "Email belum terverifikasi", + "auth_error_base_password_too_short": "Password terlalu pendek", + "auth_error_base_password_too_long": "Password terlalu panjang", + "auth_error_base_user_already_exists": "Pengguna sudah ada.", + "auth_error_base_user_already_exists_use_another_email": "Pengguna sudah ada. Gunakan email lain.", + "auth_error_base_email_can_not_be_updated": "Email tidak dapat diperbarui", + "auth_error_base_credential_account_not_found": "Akun kredensial tidak ditemukan", + "auth_error_base_session_expired": "Sesi kedaluwarsa. Silakan autentikasi ulang untuk melakukan tindakan ini.", + "auth_error_base_failed_to_unlink_last_account": "Anda tidak dapat melepas tautan akun terakhir Anda", + "auth_error_base_account_not_found": "Akun tidak ditemukan", + "auth_error_base_user_already_has_password": "Pengguna sudah memiliki password. Gunakan itu untuk menghapus akun.", + "auth_error_base_cross_site_navigation_login_blocked": "Login lintas-situs diblokir. Permintaan ini terindikasi sebagai serangan CSRF.", + "auth_error_base_verification_email_not_enabled": "Email verifikasi belum diaktifkan", + "auth_error_base_email_already_verified": "Email sudah terverifikasi", + "auth_error_base_email_mismatch": "Email tidak cocok", + "auth_error_base_session_not_fresh": "Sesi tidak lagi valid untuk aksi ini", + "auth_error_base_linked_account_already_exists": "Akun tertaut sudah ada", + "auth_error_base_invalid_origin": "Origin tidak valid", + "auth_error_base_invalid_callback_url": "callbackURL tidak valid", + "auth_error_base_invalid_redirect_url": "redirectURL tidak valid", + "auth_error_base_invalid_error_callback_url": "errorCallbackURL tidak valid", + "auth_error_base_invalid_new_user_callback_url": "newUserCallbackURL tidak valid", + "auth_error_base_missing_or_null_origin": "Origin hilang atau null", + "auth_error_base_callback_url_required": "callbackURL wajib diisi", + "auth_error_base_failed_to_create_verification": "Tidak dapat membuat verifikasi", + "auth_error_base_field_not_allowed": "Field tidak diizinkan untuk diatur", + "auth_error_base_async_validation_not_supported": "Validasi async tidak didukung", + "auth_error_base_validation_error": "Kesalahan validasi", + "auth_error_base_missing_field": "Field wajib diisi" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index b681e84..f751d56 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -5,5 +5,97 @@ "common_error_something_went_wrong": "出现了一些问题", "common_error_form_validation_title": "表单存在问题", - "common_error_form_validation_description": "请检查表单并更正后继续。" + "common_error_form_validation_description": "请检查表单并更正后继续。", + + "common_greeting_name": "嗨,{name}!", + "common_continue_as_name": "以 {name} 身份继续", + + "auth_get_started_title": "让我们开始吧", + "auth_get_started_description": "选择如何继续", + "auth_get_started_action": "开始", + "auth_get_started_signup_title": "创建您的账户", + "auth_get_started_signup_description": "初来乍到?注册创建账户,开启您的旅程。", + "auth_get_started_signin_title": "现有账户", + "auth_get_started_signin_description": "已有账户?登录以继续您上次的进度。", + + "auth_continue_with_social_provider": "⁠⁠继续使用 {provider} 继续", + "auth_or_separator": "或", + + "auth_field_full_name_label": "全名", + "auth_field_full_name_placeholder": "Devsantara 团队", + "auth_field_email_label": "邮箱", + "auth_field_email_placeholder": "team@devsantara.com", + "auth_field_password_label": "密码", + "auth_field_confirm_password_label": "确认密码", + "auth_field_error_name_required": "姓名不能为空", + "auth_field_error_email_invalid": "请输入有效的邮箱地址", + "auth_field_error_password_min_length": "密码至少需要 {min} 个字符", + "auth_field_error_confirm_password_not_match": "密码与确认密码不一致", + + "auth_sign_up_title": "创建你的账户", + "auth_sign_up_description": "请填写下方表单以创建你的账户", + "auth_sign_up_action": "创建账户", + "auth_sign_up_already_have_account": "已有账户?", + "auth_sign_up_sign_in_link": "登录", + "auth_sign_up_error_fail": "注册失败", + "auth_sign_up_success_title": "账户创建成功", + "auth_sign_up_success_description": "请登录以继续。", + + "auth_sign_in_title": "登录到你的账户", + "auth_sign_in_description": "请输入你的信息以继续。", + "auth_sign_in_action": "登录", + "auth_sign_in_fail": "登录失败", + "auth_sign_in_success_title": "欢迎回来!", + "auth_sign_in_success_description": "很高兴再次见到你。", + "auth_sign_in_no_account": "还没有账户?", + "auth_sign_in_sign_up_link": "注册", + + "auth_sign_out_action": "退出登录", + "auth_sign_out_error_fail": "退出登录失败", + "auth_sign_out_success_title": "成功退出登录", + "auth_sign_out_success_description": "期待下次再见。", + + "auth_error_base_user_not_found": "未找到用户", + "auth_error_base_failed_to_create_user": "创建用户失败", + "auth_error_base_failed_to_create_session": "创建会话失败", + "auth_error_base_failed_to_update_user": "更新用户失败", + "auth_error_base_failed_to_get_session": "获取会话失败", + "auth_error_base_invalid_password": "密码无效", + "auth_error_base_invalid_email": "邮箱无效", + "auth_error_base_invalid_email_or_password": "邮箱或密码无效", + "auth_error_base_social_account_already_linked": "社交账号已绑定", + "auth_error_base_provider_not_found": "未找到提供方", + "auth_error_base_invalid_token": "令牌无效", + "auth_error_base_id_token_not_supported": "不支持 id_token", + "auth_error_base_failed_to_get_user_info": "获取用户信息失败", + "auth_error_base_user_email_not_found": "未找到用户邮箱", + "auth_error_base_email_not_verified": "邮箱未验证", + "auth_error_base_password_too_short": "密码太短", + "auth_error_base_password_too_long": "密码太长", + "auth_error_base_user_already_exists": "用户已存在。", + "auth_error_base_user_already_exists_use_another_email": "用户已存在。请使用其他邮箱。", + "auth_error_base_email_can_not_be_updated": "邮箱无法更新", + "auth_error_base_credential_account_not_found": "未找到凭证账号", + "auth_error_base_session_expired": "会话已过期。请重新认证以执行此操作。", + "auth_error_base_failed_to_unlink_last_account": "你不能解绑最后一个账号", + "auth_error_base_account_not_found": "未找到账号", + "auth_error_base_user_already_has_password": "用户已设置密码。请提供该密码以删除账号。", + "auth_error_base_cross_site_navigation_login_blocked": "已阻止跨站导航登录。此请求疑似 CSRF 攻击。", + "auth_error_base_verification_email_not_enabled": "未启用验证邮件", + "auth_error_base_email_already_verified": "邮箱已验证", + "auth_error_base_email_mismatch": "邮箱不匹配", + "auth_error_base_session_not_fresh": "会话不够新", + "auth_error_base_linked_account_already_exists": "已存在绑定的账号", + "auth_error_base_invalid_origin": "Origin 无效", + "auth_error_base_invalid_callback_url": "callbackURL 无效", + "auth_error_base_invalid_redirect_url": "redirectURL 无效", + "auth_error_base_invalid_error_callback_url": "errorCallbackURL 无效", + "auth_error_base_invalid_new_user_callback_url": "newUserCallbackURL 无效", + "auth_error_base_missing_or_null_origin": "Origin 缺失或为 null", + "auth_error_base_callback_url_required": "callbackURL 为必填项", + "auth_error_base_failed_to_create_verification": "无法创建验证", + "auth_error_base_field_not_allowed": "不允许设置该字段", + "auth_error_base_async_validation_not_supported": "不支持异步校验", + "auth_error_base_validation_error": "验证错误", + "auth_error_base_missing_field": "字段为必填项" } diff --git a/package.json b/package.json index d64458a..c5ab8ed 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "@tanstack/react-form-start": "^1.27.7", "@tanstack/react-router": "^1.144.0", "@tanstack/react-start": "^1.145.5", + "@tanstack/zod-adapter": "^1.157.16", + "better-auth": "^1.4.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e91f27..c0fb47a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: '@tanstack/react-start': specifier: ^1.145.5 version: 1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/zod-adapter': + specifier: ^1.157.16 + version: 1.157.16(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(zod@4.3.5) + better-auth: + specifier: ^1.4.15 + version: 1.4.15(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -59,7 +65,7 @@ importers: version: 4.1.0 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.27.6) + version: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.28.9) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) @@ -174,7 +180,7 @@ importers: version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) alchemy: specifier: ^0.83.0 - version: 0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(@libsql/client@0.17.0)(kysely@0.27.6)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)) + version: 0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(@libsql/client@0.17.0)(kysely@0.28.9)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -450,6 +456,27 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.15': + resolution: {integrity: sha512-uAvq8YA7SaS7v+TrvH/Kwt7LAJihzUqB3FX8VweDsqu3gn5t51M+Bve+V1vVWR9qBAtC6cN68V6b+scxZxDY4A==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.15': + resolution: {integrity: sha512-7NW/2PS4RN85rv+ozpAezP/kSLPZeWkxqcA6RA/CFXqWp2YR2e5q5E6Hym1qBgVBkoAQa3lWFdX3b+jEs+vvrQ==} + peerDependencies: + '@better-auth/core': 1.4.15 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@cloudflare/kv-asset-handler@0.4.1': resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} engines: {node: '>=18.0.0'} @@ -1611,6 +1638,14 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodeutils/defaults-deep@1.1.0': resolution: {integrity: sha512-gG44cwQovaOFdSR02jR9IhVRpnDP64VN6JdjYJTfNz4J4fWn7TQnmrf22nSjRqlwlxPcW8PL/L3KbJg3tdwvpg==} @@ -2985,6 +3020,9 @@ packages: resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} hasBin: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-docs@10.1.11': resolution: {integrity: sha512-Jwm291Fhim2eVcZIVlkG1B2skb0ZI9oru6nqMbJxceQZlvZmcIa4oxvS1oaMTKw2DJnCv97gLm57P/YvRZ8eUg==} peerDependencies: @@ -3378,6 +3416,13 @@ packages: resolution: {integrity: sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ==} engines: {node: '>=12'} + '@tanstack/zod-adapter@1.157.16': + resolution: {integrity: sha512-FZoWFtMqWDym/KDiGlwuQLhp//m8lMf4MjgpUqH37X9+WwxjaZXjrbED4lozphhLNaQUum9IH5+354YWTqzYtA==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/react-router': '>=1.43.2' + zod: ^3.23.8 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3696,6 +3741,76 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-auth@1.4.15: + resolution: {integrity: sha512-XZr4GnFPbjvf8wip8AAjTrpGNn3Sba600zT+DgsR3NNCMWCt9aD8+nuRah6BHwHWnVP1nfnby07tPmti72SRBw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -4840,6 +4955,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -4884,6 +5002,10 @@ packages: resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} engines: {node: '>=14.0.0'} + kysely@0.28.9: + resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} + engines: {node: '>=20.0.0'} + launch-editor@2.12.0: resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==} @@ -5166,6 +5288,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5681,6 +5807,9 @@ packages: resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==} engines: {node: '>=10'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -6814,6 +6943,27 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.4.15(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.5) + jose: 6.1.3 + kysely: 0.28.9 + nanostores: 1.1.0 + zod: 4.3.5 + + '@better-auth/telemetry@1.4.15(@better-auth/core@1.4.15(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.15(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + '@cloudflare/kv-asset-handler@0.4.1': dependencies: mime: 3.0.0 @@ -7699,6 +7849,10 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@noble/ciphers@2.1.1': {} + + '@noble/hashes@2.0.1': {} + '@nodeutils/defaults-deep@1.1.0': dependencies: lodash: 4.17.21 @@ -9161,6 +9315,8 @@ snapshots: '@sqlite.org/sqlite-wasm@3.48.0-build4': {} + '@standard-schema/spec@1.1.0': {} + '@storybook/addon-docs@10.1.11(@types/react@19.2.7)(esbuild@0.25.12)(rollup@4.55.1)(storybook@10.1.11(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.3) @@ -9663,6 +9819,11 @@ snapshots: '@tanstack/virtual-file-routes@1.145.4': {} + '@tanstack/zod-adapter@1.157.16(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(zod@4.3.5)': + dependencies: + '@tanstack/react-router': 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + zod: 4.3.5 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -9843,7 +10004,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(@libsql/client@0.17.0)(kysely@0.27.6)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)): + alchemy@0.83.0(@cloudflare/vite-plugin@1.19.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)))(@libsql/client@0.17.0)(kysely@0.28.9)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20251217.0)(wrangler@4.56.0(@cloudflare/workers-types@4.20260103.0)): dependencies: '@aws-sdk/credential-providers': 3.962.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20251217.0) @@ -9853,7 +10014,7 @@ snapshots: '@smithy/node-config-provider': 4.3.7 '@smithy/types': 4.11.0 aws4fetch: 1.0.20 - drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.27.6) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.28.9) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -10004,6 +10165,37 @@ snapshots: before-after-hook@4.0.0: {} + better-auth@1.4.15(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10): + dependencies: + '@better-auth/core': 1.4.15(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.15(@better-auth/core@1.4.15(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.5) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.9 + nanostores: 1.1.0 + zod: 4.3.5 + optionalDependencies: + '@tanstack/react-start': 1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + drizzle-kit: 0.31.8 + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.28.9) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + solid-js: 1.9.10 + + better-call@1.1.8(zod@4.3.5): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.5 + binary-extensions@2.3.0: {} blake3-wasm@2.1.5: {} @@ -10476,11 +10668,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.27.6): + drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@libsql/client@0.17.0)(kysely@0.28.9): optionalDependencies: '@cloudflare/workers-types': 4.20260103.0 '@libsql/client': 0.17.0 - kysely: 0.27.6 + kysely: 0.28.9 dunder-proto@1.0.1: dependencies: @@ -11118,6 +11310,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-base64@3.7.8: {} js-sha256@0.11.1: {} @@ -11149,6 +11343,8 @@ snapshots: kysely@0.27.6: {} + kysely@0.28.9: {} + launch-editor@2.12.0: dependencies: picocolors: 1.1.1 @@ -11395,6 +11591,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.1.0: {} + neo-async@2.6.2: {} netmask@2.0.2: {} @@ -12037,6 +12235,8 @@ snapshots: seroval@1.4.2: {} + set-cookie-parser@2.7.2: {} + setimmediate@1.0.5: {} sharp@0.33.5: diff --git a/skills-lock.json b/skills-lock.json index 302c5dc..cb2ab93 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,11 +1,41 @@ { "version": 1, "skills": { + "better-auth-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "a24359053259e55a75972cab4bf0b0e37e1393bac5f3756ec2e3a7b4b3778d2a" + }, + "better-auth-security-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "294d13881e083d41ff874b1da6689faf0c9eee4120579c0148a7a9c2aaa83e0c" + }, + "create-auth-skill": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "63689cc54dad93d286af948ab1792e755e7063541ffe90e248eed6523b0748a0" + }, + "email-and-password-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "9105cc29a462e8d07da45bbdbc90c3a60be3ba32fbd9d99d49944f5b971f8f96" + }, "git-commit": { "source": "github/awesome-copilot", "sourceType": "github", "computedHash": "2607fc60629b82b257136dd2a7a373f0a4466c0b49df7746d845d59313c99b21" }, + "organization-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "ec7bfca730d9263a357bfb059448035e62339ee58eb6191dfe9029088ed5bdf2" + }, + "two-factor-authentication-best-practices": { + "source": "better-auth/skills", + "sourceType": "github", + "computedHash": "37c04cd3de40b33601f2d2dfb290bde6dcc873badbdeace7d2a0e73dd055b270" + }, "vercel-composition-patterns": { "source": "vercel-labs/agent-skills", "sourceType": "github", diff --git a/src/features/auth-sign-in/auth-sign-in.form.tsx b/src/features/auth-sign-in/auth-sign-in.form.tsx new file mode 100644 index 0000000..21a71eb --- /dev/null +++ b/src/features/auth-sign-in/auth-sign-in.form.tsx @@ -0,0 +1,112 @@ +import { useHydrated, useNavigate } from '@tanstack/react-router'; + +import { authSignInSchema } from '~/features/auth-sign-in/auth-sign-in.schema'; +import { authClient } from '~/lib/auth/client'; +import { useAppForm } from '~/lib/form'; +import { createFormError } from '~/lib/form/form.utils'; +import { m } from '~/lib/i18n/messages'; +import { AuthSocialButton } from '~/modules/auth/components/auth-social-button'; +import { toast } from '~/ui/components/core/sonner'; + +export function AuthSignInForm({ + redirectBack, + ...props +}: React.ComponentProps<'form'> & { redirectBack?: string }) { + const isHydrated = useHydrated(); + const navigate = useNavigate(); + + const form = useAppForm({ + formId: 'auth-sign-in', + validators: { + onSubmit: authSignInSchema, + }, + defaultValues: { + email: '', + password: '', + }, + async onSubmit({ value, formApi }) { + const { error } = await authClient.signIn.email({ + email: value.email, + password: value.password, + }); + + if (error) { + toast.error(m.auth_sign_in_fail(), { + description: error.message, + }); + return formApi.setErrorMap({ + onSubmit: { + form: createFormError({ + title: m.auth_sign_in_fail(), + message: error.message, + }), + fields: {}, + }, + }); + } + + toast.success(m.auth_sign_in_success_title(), { + description: m.auth_sign_in_success_description(), + }); + await navigate({ to: redirectBack ?? '/app' }); + }, + }); + + return ( + + + + + + {(field) => ( + + + {m.auth_field_email_label()} + + + + + )} + + + + {(field) => ( + + + {m.auth_field_password_label()} + + + + + )} + + + + + {m.auth_sign_in_action()} + + + + + + {m.auth_or_separator()} + + + + + + + + + + + ); +} diff --git a/src/features/auth-sign-in/auth-sign-in.schema.ts b/src/features/auth-sign-in/auth-sign-in.schema.ts new file mode 100644 index 0000000..acdb445 --- /dev/null +++ b/src/features/auth-sign-in/auth-sign-in.schema.ts @@ -0,0 +1,15 @@ +import * as z from 'zod/v4'; + +import { AUTH_MIN_PASSWORD_LENGTH } from '~/lib/auth/constant'; +import { m } from '~/lib/i18n/messages'; + +export const authSignInSchema = z.object({ + email: z.email(m.auth_field_error_email_invalid()), + password: z.string().min( + AUTH_MIN_PASSWORD_LENGTH, + m.auth_field_error_password_min_length({ + min: AUTH_MIN_PASSWORD_LENGTH, + }), + ), +}); +export type AuthSignInSchema = z.infer; diff --git a/src/features/auth-sign-up/auth-sign-up.form.tsx b/src/features/auth-sign-up/auth-sign-up.form.tsx new file mode 100644 index 0000000..8dc95a4 --- /dev/null +++ b/src/features/auth-sign-up/auth-sign-up.form.tsx @@ -0,0 +1,158 @@ +import { useHydrated, useNavigate } from '@tanstack/react-router'; + +import { authSignUpSchema } from '~/features/auth-sign-up/auth-sign-up.schema'; +import { authClient } from '~/lib/auth/client'; +import { useAppForm } from '~/lib/form'; +import { createFormError } from '~/lib/form/form.utils'; +import { m } from '~/lib/i18n/messages'; +import { AuthSocialButton } from '~/modules/auth/components/auth-social-button'; +import { toast } from '~/ui/components/core/sonner'; + +export function AuthSignUpForm({ + redirectBack, + ...props +}: React.ComponentProps<'form'> & { redirectBack?: string }) { + const isHydrated = useHydrated(); + const navigate = useNavigate(); + + const form = useAppForm({ + formId: 'auth-sign-up', + validators: { + onSubmit: authSignUpSchema, + }, + defaultValues: { + name: '', + email: '', + password: '', + confirmPassword: '', + }, + async onSubmit({ value, formApi }) { + const { error } = await authClient.signUp.email({ + name: value.name, + email: value.email, + password: value.password, + }); + + if (error) { + toast.error(m.auth_sign_up_error_fail(), { + description: error.message, + }); + return formApi.setErrorMap({ + onSubmit: { + form: createFormError({ + title: m.auth_sign_up_error_fail(), + message: error.message, + }), + fields: {}, + }, + }); + } + + toast.success(m.auth_sign_up_success_title(), { + description: m.auth_sign_up_success_description(), + }); + await navigate({ to: '/auth/sign-in', search: { redirectBack } }); + }, + }); + + return ( + + + + + + {(field) => ( + + + {m.auth_field_full_name_label()} + + + + + )} + + + + {(field) => ( + + + {m.auth_field_email_label()} + + + + + )} + + + + + + {(field) => ( + + + {m.auth_field_password_label()} + + + + )} + + + + {(field) => ( + + + {m.auth_field_confirm_password_label()} + + + + )} + + + + [ + state.fieldMeta.password?.errors, + state.fieldMeta.confirmPassword?.errors, + ]} + > + {(errors) => } + + + + + + {m.auth_sign_up_action()} + + + + + + {m.auth_or_separator()} + + + + + + + + + + + ); +} diff --git a/src/features/auth-sign-up/auth-sign-up.schema.ts b/src/features/auth-sign-up/auth-sign-up.schema.ts new file mode 100644 index 0000000..b0a18a0 --- /dev/null +++ b/src/features/auth-sign-up/auth-sign-up.schema.ts @@ -0,0 +1,22 @@ +import * as z from 'zod/v4'; + +import { AUTH_MIN_PASSWORD_LENGTH } from '~/lib/auth/constant'; +import { m } from '~/lib/i18n/messages'; + +export const authSignUpSchema = z + .object({ + name: z.string().nonempty(m.auth_field_error_name_required()), + email: z.email(m.auth_field_error_email_invalid()), + password: z.string().min( + AUTH_MIN_PASSWORD_LENGTH, + m.auth_field_error_password_min_length({ + min: AUTH_MIN_PASSWORD_LENGTH, + }), + ), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: m.auth_field_error_confirm_password_not_match(), + path: ['confirmPassword'], + }); +export type AuthSignUpSchema = z.infer; diff --git a/src/lib/auth/client.ts b/src/lib/auth/client.ts new file mode 100644 index 0000000..4f4fbef --- /dev/null +++ b/src/lib/auth/client.ts @@ -0,0 +1,3 @@ +import { createAuthClient } from 'better-auth/react'; + +export const authClient = createAuthClient(); diff --git a/src/lib/auth/constant.ts b/src/lib/auth/constant.ts new file mode 100644 index 0000000..dabfc02 --- /dev/null +++ b/src/lib/auth/constant.ts @@ -0,0 +1,2 @@ +export const AUTH_MIN_TTL_IN_SECONDS = 60; +export const AUTH_MIN_PASSWORD_LENGTH = 8; diff --git a/src/lib/auth/errors.ts b/src/lib/auth/errors.ts new file mode 100644 index 0000000..3aaddb1 --- /dev/null +++ b/src/lib/auth/errors.ts @@ -0,0 +1,62 @@ +import type { AuthErrors } from '~/lib/auth/types'; +import { m } from '~/lib/i18n/messages'; + +export function getAuthErrorMessage(code: keyof AuthErrors | (string & {})) { + const AUTH_ERROR_CODES: AuthErrors = { + USER_NOT_FOUND: m.auth_error_base_user_not_found(), + FAILED_TO_CREATE_USER: m.auth_error_base_failed_to_create_user(), + FAILED_TO_CREATE_SESSION: m.auth_error_base_failed_to_create_session(), + FAILED_TO_UPDATE_USER: m.auth_error_base_failed_to_update_user(), + FAILED_TO_GET_SESSION: m.auth_error_base_failed_to_get_session(), + INVALID_PASSWORD: m.auth_error_base_invalid_password(), + INVALID_EMAIL: m.auth_error_base_invalid_email(), + INVALID_EMAIL_OR_PASSWORD: m.auth_error_base_invalid_email_or_password(), + SOCIAL_ACCOUNT_ALREADY_LINKED: + m.auth_error_base_social_account_already_linked(), + PROVIDER_NOT_FOUND: m.auth_error_base_provider_not_found(), + INVALID_TOKEN: m.auth_error_base_invalid_token(), + ID_TOKEN_NOT_SUPPORTED: m.auth_error_base_id_token_not_supported(), + FAILED_TO_GET_USER_INFO: m.auth_error_base_failed_to_get_user_info(), + USER_EMAIL_NOT_FOUND: m.auth_error_base_user_email_not_found(), + EMAIL_NOT_VERIFIED: m.auth_error_base_email_not_verified(), + PASSWORD_TOO_SHORT: m.auth_error_base_password_too_short(), + PASSWORD_TOO_LONG: m.auth_error_base_password_too_long(), + USER_ALREADY_EXISTS: m.auth_error_base_user_already_exists(), + USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: + m.auth_error_base_user_already_exists_use_another_email(), + EMAIL_CAN_NOT_BE_UPDATED: m.auth_error_base_email_can_not_be_updated(), + CREDENTIAL_ACCOUNT_NOT_FOUND: + m.auth_error_base_credential_account_not_found(), + SESSION_EXPIRED: m.auth_error_base_session_expired(), + FAILED_TO_UNLINK_LAST_ACCOUNT: + m.auth_error_base_failed_to_unlink_last_account(), + ACCOUNT_NOT_FOUND: m.auth_error_base_account_not_found(), + USER_ALREADY_HAS_PASSWORD: m.auth_error_base_user_already_has_password(), + CROSS_SITE_NAVIGATION_LOGIN_BLOCKED: + m.auth_error_base_cross_site_navigation_login_blocked(), + VERIFICATION_EMAIL_NOT_ENABLED: + m.auth_error_base_verification_email_not_enabled(), + EMAIL_ALREADY_VERIFIED: m.auth_error_base_email_already_verified(), + EMAIL_MISMATCH: m.auth_error_base_email_mismatch(), + SESSION_NOT_FRESH: m.auth_error_base_session_not_fresh(), + LINKED_ACCOUNT_ALREADY_EXISTS: + m.auth_error_base_linked_account_already_exists(), + INVALID_ORIGIN: m.auth_error_base_invalid_origin(), + INVALID_CALLBACK_URL: m.auth_error_base_invalid_callback_url(), + INVALID_REDIRECT_URL: m.auth_error_base_invalid_redirect_url(), + INVALID_ERROR_CALLBACK_URL: m.auth_error_base_invalid_error_callback_url(), + INVALID_NEW_USER_CALLBACK_URL: + m.auth_error_base_invalid_new_user_callback_url(), + MISSING_OR_NULL_ORIGIN: m.auth_error_base_missing_or_null_origin(), + CALLBACK_URL_REQUIRED: m.auth_error_base_callback_url_required(), + FAILED_TO_CREATE_VERIFICATION: + m.auth_error_base_failed_to_create_verification(), + FIELD_NOT_ALLOWED: m.auth_error_base_field_not_allowed(), + ASYNC_VALIDATION_NOT_SUPPORTED: + m.auth_error_base_async_validation_not_supported(), + VALIDATION_ERROR: m.auth_error_base_validation_error(), + MISSING_FIELD: m.auth_error_base_missing_field(), + }; + + return AUTH_ERROR_CODES[code as keyof AuthErrors] as string | undefined; +} diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts new file mode 100644 index 0000000..2c6147c --- /dev/null +++ b/src/lib/auth/server.ts @@ -0,0 +1,114 @@ +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { APIError, createAuthMiddleware } from 'better-auth/api'; +import { betterAuth } from 'better-auth/minimal'; +import { tanstackStartCookies } from 'better-auth/tanstack-start'; +import { env } from 'cloudflare:workers'; + +import { + AUTH_MIN_PASSWORD_LENGTH, + AUTH_MIN_TTL_IN_SECONDS, +} from '~/lib/auth/constant'; +import { getAuthErrorMessage } from '~/lib/auth/errors'; +import { getDatabase } from '~/lib/database'; +import { + accountTable, + sessionTable, + userTable, + verificationTable, +} from '~/lib/database/schema/auth.db'; +import { serverEnv } from '~/lib/env/server'; +import { m } from '~/lib/i18n/messages'; + +export const authServer = betterAuth({ + appName: m.app_name(), + secret: serverEnv.AUTH_SECRET, + rateLimit: { + enabled: true, + storage: 'secondary-storage', + window: 60, // time window in seconds + max: 100, // max requests in the window + }, + database: drizzleAdapter(getDatabase(), { + provider: 'sqlite', + usePlural: true, + schema: { + users: userTable, + sessions: sessionTable, + accounts: accountTable, + verifications: verificationTable, + }, + }), + secondaryStorage: { + get: async (key) => { + return await env.KV.get(key); + }, + set: async (key, value, ttl) => { + if (!ttl) return await env.KV.put(key, value); + // Cloudflare Workers KV has a minimum TTL of 60 seconds. + // If the provided TTL is less than that, we set it to the minimum. + let expirationTtl = ttl; + if (expirationTtl < AUTH_MIN_TTL_IN_SECONDS) { + expirationTtl = AUTH_MIN_TTL_IN_SECONDS; + } + return await env.KV.put(key, value, { expirationTtl }); + }, + delete: async (key) => { + return await env.KV.delete(key); + }, + }, + plugins: [tanstackStartCookies()], + emailAndPassword: { + enabled: true, + autoSignIn: false, + minPasswordLength: AUTH_MIN_PASSWORD_LENGTH, + }, + socialProviders: { + google: { + accessType: 'offline', + prompt: 'select_account consent', + clientId: serverEnv.AUTH_GOOGLE_CLIENT_ID, + clientSecret: serverEnv.AUTH_GOOGLE_CLIENT_SECRET, + }, + github: { + prompt: 'select_account', + clientId: serverEnv.AUTH_GITHUB_CLIENT_ID, + clientSecret: serverEnv.AUTH_GITHUB_CLIENT_SECRET, + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // 1 day + freshAge: 60 * 60 * 24, // 1 day + cookieCache: { + enabled: true, + strategy: 'compact', + maxAge: 5 * 60, // 5 minutes + }, + }, + advanced: { + cookiePrefix: 'auth', + ipAddress: { + ipAddressHeaders: ['CF-Connecting-IP', 'X-Forwarded-For'], + }, + database: { + generateId: 'uuid', + }, + }, + hooks: { + // oxlint-disable-next-line require-await + after: createAuthMiddleware(async (ctx) => { + const response = ctx.context.returned; + if (!(response instanceof APIError)) { + return; + } + const errorCode = response.body?.code; + if (!errorCode) { + throw new APIError(response.status, response.body); + } + throw new APIError(response.status, { + ...response.body, + message: getAuthErrorMessage(errorCode) ?? response.body?.message, + }); + }), + }, +}); diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts new file mode 100644 index 0000000..76a5553 --- /dev/null +++ b/src/lib/auth/types.ts @@ -0,0 +1,7 @@ +import type { authClient } from '~/lib/auth/client'; +import { authServer } from '~/lib/auth/server'; + +export type AuthErrors = Record< + keyof typeof authServer.$ERROR_CODES | keyof typeof authClient.$ERROR_CODES, + string +>; diff --git a/src/lib/database/migrations/0000_create_auth_tables.sql b/src/lib/database/migrations/0000_create_auth_tables.sql new file mode 100644 index 0000000..65e69b3 --- /dev/null +++ b/src/lib/database/migrations/0000_create_auth_tables.sql @@ -0,0 +1,54 @@ +CREATE TABLE `accounts` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `accounts_userId_idx` ON `accounts` (`user_id`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint +CREATE INDEX `sessions_userId_idx` ON `sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `sessions_token_idx` ON `sessions` (`token`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +CREATE TABLE `verifications` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `verifications_identifier_idx` ON `verifications` (`identifier`); \ No newline at end of file diff --git a/src/lib/database/migrations/meta/0000_snapshot.json b/src/lib/database/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..15e52f7 --- /dev/null +++ b/src/lib/database/migrations/meta/0000_snapshot.json @@ -0,0 +1,377 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "42acc1b1-6035-4ae7-af85-b2f6b2778ba2", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/database/migrations/meta/_journal.json b/src/lib/database/migrations/meta/_journal.json new file mode 100644 index 0000000..70044f8 --- /dev/null +++ b/src/lib/database/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1768720220485, + "tag": "0000_create_auth_tables", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/lib/database/schema/auth.db.ts b/src/lib/database/schema/auth.db.ts new file mode 100644 index 0000000..4257305 --- /dev/null +++ b/src/lib/database/schema/auth.db.ts @@ -0,0 +1,110 @@ +import { relations, sql } from 'drizzle-orm'; +import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; + +export const userTable = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }) + .default(false) + .notNull(), + image: text('image'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const sessionTable = sqliteTable( + 'sessions', + { + id: text('id').primaryKey(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + token: text('token').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => userTable.id, { onDelete: 'cascade' }), + }, + (table) => [ + index('sessions_userId_idx').on(table.userId), + index('sessions_token_idx').on(table.token), + ], +); + +export const accountTable = sqliteTable( + 'accounts', + { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => userTable.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { + mode: 'timestamp_ms', + }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { + mode: 'timestamp_ms', + }), + scope: text('scope'), + password: text('password'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index('accounts_userId_idx').on(table.userId)], +); + +export const verificationTable = sqliteTable( + 'verifications', + { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index('verifications_identifier_idx').on(table.identifier)], +); + +export const userRelations = relations(userTable, ({ many }) => ({ + sessions: many(sessionTable), + accounts: many(accountTable), +})); + +export const sessionRelations = relations(sessionTable, ({ one }) => ({ + user: one(userTable, { + fields: [sessionTable.userId], + references: [userTable.id], + }), +})); + +export const accountRelations = relations(accountTable, ({ one }) => ({ + user: one(userTable, { + fields: [accountTable.userId], + references: [userTable.id], + }), +})); diff --git a/src/lib/env/server.ts b/src/lib/env/server.ts index 9f05b3e..92e9dc1 100644 --- a/src/lib/env/server.ts +++ b/src/lib/env/server.ts @@ -1,9 +1,15 @@ import { createEnv } from '@t3-oss/env-core'; -import * as _ from 'zod/v4'; +import * as z from 'zod/v4'; /** Env schema for server bundle */ export const serverEnv = createEnv({ - server: {}, + server: { + AUTH_SECRET: z.string(), + AUTH_GITHUB_CLIENT_ID: z.string(), + AUTH_GITHUB_CLIENT_SECRET: z.string(), + AUTH_GOOGLE_CLIENT_ID: z.string(), + AUTH_GOOGLE_CLIENT_SECRET: z.string(), + }, runtimeEnv: process.env, emptyStringAsUndefined: true, }); diff --git a/src/lib/form/components/form-error.tsx b/src/lib/form/components/form-error.tsx index d3a69b0..856cdf6 100644 --- a/src/lib/form/components/form-error.tsx +++ b/src/lib/form/components/form-error.tsx @@ -31,7 +31,10 @@ export function FormError() { : m.common_error_form_validation_description(); return ( - + {title} {message && {message}} diff --git a/src/modules/auth/auth.fn.ts b/src/modules/auth/auth.fn.ts new file mode 100644 index 0000000..e16d5a8 --- /dev/null +++ b/src/modules/auth/auth.fn.ts @@ -0,0 +1,24 @@ +import { createServerFn } from '@tanstack/react-start'; +import { getRequestHeaders } from '@tanstack/react-start/server'; + +import { authServer } from '~/lib/auth/server'; + +/** + * A server function to get the current authenticated user and session. + * Returns `null` if no user is authenticated. + */ +export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler( + async () => { + const headers = getRequestHeaders(); + const authSession = await authServer.api.getSession({ + headers, + }); + if (!authSession) { + return null; + } + return { + ...authSession.user, + session: authSession.session, + }; + }, +); diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..9c46126 --- /dev/null +++ b/src/modules/auth/auth.schema.ts @@ -0,0 +1,11 @@ +import * as z from 'zod/v4'; + +export const authSearchParamsSchema = z.object({ + /** + * Path of the protected page the user was trying to access before being redirected to auth. + * When an unauthenticated user attempts to access a protected route, they are redirected + * to the authentication page with this parameter containing the original path. After successful + * authentication, the user will be redirected back to this path instead of the default post-auth page. + */ + redirectBack: z.string().optional(), +}); diff --git a/src/modules/auth/auth.utils.ts b/src/modules/auth/auth.utils.ts new file mode 100644 index 0000000..b4002e3 --- /dev/null +++ b/src/modules/auth/auth.utils.ts @@ -0,0 +1,39 @@ +import { redirect } from '@tanstack/react-router'; + +import { getCurrentUserFn } from '~/modules/auth/auth.fn'; + +/** + * A utility function that asserts the current user is authenticated. + * If not authenticated, it redirects to the auth page. + * If authenticated, it returns the user object. + */ +export async function authUserGuard({ + redirectBack, +}: { + redirectBack?: string; +}) { + const user = await getCurrentUserFn(); + const isAuthenticated = user !== null; + if (!isAuthenticated) { + throw redirect({ + to: '/auth', + replace: true, + search: { redirectBack }, + }); + } + return user; +} + +/** + * A utility function that asserts the current user is a guest (not authenticated). + * If authenticated, it redirects to the app page. + * If not authenticated, it returns void. + */ +export async function authGuestGuard() { + const user = await getCurrentUserFn(); + const isAuthenticated = user !== null; + if (isAuthenticated) { + throw redirect({ to: '/app', replace: true }); + } + return; +} diff --git a/src/modules/auth/components/auth-social-button.tsx b/src/modules/auth/components/auth-social-button.tsx new file mode 100644 index 0000000..aba5d00 --- /dev/null +++ b/src/modules/auth/components/auth-social-button.tsx @@ -0,0 +1,67 @@ +import { authClient } from '~/lib/auth/client'; +import { m } from '~/lib/i18n/messages'; +import { + GitHubIcon, + GoogleIcon, +} from '~/modules/auth/components/auth-social-provider-icon'; +import { Button } from '~/ui/components/core/button'; +import { toast } from '~/ui/components/core/sonner'; + +const SOCIAL_PROVIDERS = { + google: { + icon: , + name: 'Google', + }, + github: { + icon: , + name: 'GitHub', + }, +} as const; + +type SocialProvider = keyof typeof SOCIAL_PROVIDERS; + +export function AuthSocialButton({ + provider, + redirectBack, + ...props +}: React.ComponentProps & { + provider: SocialProvider; + redirectBack?: string; +}) { + const socialProvider = SOCIAL_PROVIDERS[provider]; + + async function handleSocialSignIn() { + const { error } = await authClient.signIn.social({ + provider, + callbackURL: redirectBack ?? '/app', + }); + + if (error) { + toast.error(m.auth_sign_in_fail(), { + description: error.message, + }); + return; + } + + toast.success(m.auth_sign_in_success_title(), { + description: m.auth_sign_in_success_description(), + }); + } + + return ( + + ); +} diff --git a/src/modules/auth/components/auth-social-provider-icon.tsx b/src/modules/auth/components/auth-social-provider-icon.tsx new file mode 100644 index 0000000..71fcc75 --- /dev/null +++ b/src/modules/auth/components/auth-social-provider-icon.tsx @@ -0,0 +1,89 @@ +export interface ProviderIconProps extends React.ComponentProps<'svg'> {} + +export function GitHubIcon({ className, ...props }: ProviderIconProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function GoogleIcon({ className, ...props }: ProviderIconProps) { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1a07a3f..1f0a0f5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,38 +9,133 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as AuthLayoutRouteImport } from './routes/auth/layout' +import { Route as AppLayoutRouteImport } from './routes/app/layout' import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthIndexRouteImport } from './routes/auth/index' +import { Route as AppIndexRouteImport } from './routes/app/index' +import { Route as AuthSignUpRouteImport } from './routes/auth/sign-up' +import { Route as AuthSignInRouteImport } from './routes/auth/sign-in' +import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' +const AuthLayoutRoute = AuthLayoutRouteImport.update({ + id: '/auth', + path: '/auth', + getParentRoute: () => rootRouteImport, +} as any) +const AppLayoutRoute = AppLayoutRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const AuthIndexRoute = AuthIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthLayoutRoute, +} as any) +const AppIndexRoute = AppIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppLayoutRoute, +} as any) +const AuthSignUpRoute = AuthSignUpRouteImport.update({ + id: '/sign-up', + path: '/sign-up', + getParentRoute: () => AuthLayoutRoute, +} as any) +const AuthSignInRoute = AuthSignInRouteImport.update({ + id: '/sign-in', + path: '/sign-in', + getParentRoute: () => AuthLayoutRoute, +} as any) +const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ + id: '/api/auth/$', + path: '/api/auth/$', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/app': typeof AppLayoutRouteWithChildren + '/auth': typeof AuthLayoutRouteWithChildren + '/auth/sign-in': typeof AuthSignInRoute + '/auth/sign-up': typeof AuthSignUpRoute + '/app/': typeof AppIndexRoute + '/auth/': typeof AuthIndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/auth/sign-in': typeof AuthSignInRoute + '/auth/sign-up': typeof AuthSignUpRoute + '/app': typeof AppIndexRoute + '/auth': typeof AuthIndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/app': typeof AppLayoutRouteWithChildren + '/auth': typeof AuthLayoutRouteWithChildren + '/auth/sign-in': typeof AuthSignInRoute + '/auth/sign-up': typeof AuthSignUpRoute + '/app/': typeof AppIndexRoute + '/auth/': typeof AuthIndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: + | '/' + | '/app' + | '/auth' + | '/auth/sign-in' + | '/auth/sign-up' + | '/app/' + | '/auth/' + | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/auth/sign-in' | '/auth/sign-up' | '/app' | '/auth' | '/api/auth/$' + id: + | '__root__' + | '/' + | '/app' + | '/auth' + | '/auth/sign-in' + | '/auth/sign-up' + | '/app/' + | '/auth/' + | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AppLayoutRoute: typeof AppLayoutRouteWithChildren + AuthLayoutRoute: typeof AuthLayoutRouteWithChildren + ApiAuthSplatRoute: typeof ApiAuthSplatRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/auth': { + id: '/auth' + path: '/auth' + fullPath: '/auth' + preLoaderRoute: typeof AuthLayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppLayoutRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -48,11 +143,77 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/auth/': { + id: '/auth/' + path: '/' + fullPath: '/auth/' + preLoaderRoute: typeof AuthIndexRouteImport + parentRoute: typeof AuthLayoutRoute + } + '/app/': { + id: '/app/' + path: '/' + fullPath: '/app/' + preLoaderRoute: typeof AppIndexRouteImport + parentRoute: typeof AppLayoutRoute + } + '/auth/sign-up': { + id: '/auth/sign-up' + path: '/sign-up' + fullPath: '/auth/sign-up' + preLoaderRoute: typeof AuthSignUpRouteImport + parentRoute: typeof AuthLayoutRoute + } + '/auth/sign-in': { + id: '/auth/sign-in' + path: '/sign-in' + fullPath: '/auth/sign-in' + preLoaderRoute: typeof AuthSignInRouteImport + parentRoute: typeof AuthLayoutRoute + } + '/api/auth/$': { + id: '/api/auth/$' + path: '/api/auth/$' + fullPath: '/api/auth/$' + preLoaderRoute: typeof ApiAuthSplatRouteImport + parentRoute: typeof rootRouteImport + } } } +interface AppLayoutRouteChildren { + AppIndexRoute: typeof AppIndexRoute +} + +const AppLayoutRouteChildren: AppLayoutRouteChildren = { + AppIndexRoute: AppIndexRoute, +} + +const AppLayoutRouteWithChildren = AppLayoutRoute._addFileChildren( + AppLayoutRouteChildren, +) + +interface AuthLayoutRouteChildren { + AuthSignInRoute: typeof AuthSignInRoute + AuthSignUpRoute: typeof AuthSignUpRoute + AuthIndexRoute: typeof AuthIndexRoute +} + +const AuthLayoutRouteChildren: AuthLayoutRouteChildren = { + AuthSignInRoute: AuthSignInRoute, + AuthSignUpRoute: AuthSignUpRoute, + AuthIndexRoute: AuthIndexRoute, +} + +const AuthLayoutRouteWithChildren = AuthLayoutRoute._addFileChildren( + AuthLayoutRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AppLayoutRoute: AppLayoutRouteWithChildren, + AuthLayoutRoute: AuthLayoutRouteWithChildren, + ApiAuthSplatRoute: ApiAuthSplatRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/api/auth.$.ts b/src/routes/api/auth.$.ts new file mode 100644 index 0000000..6e3fb32 --- /dev/null +++ b/src/routes/api/auth.$.ts @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { authServer } from '~/lib/auth/server'; + +export const Route = createFileRoute('/api/auth/$')({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + return await authServer.handler(request); + }, + POST: async ({ request }: { request: Request }) => { + return await authServer.handler(request); + }, + }, + }, +}); diff --git a/src/routes/app/index.tsx b/src/routes/app/index.tsx new file mode 100644 index 0000000..427686b --- /dev/null +++ b/src/routes/app/index.tsx @@ -0,0 +1,56 @@ +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; +import { HomeIcon } from 'lucide-react'; + +import { authClient } from '~/lib/auth/client'; +import { m } from '~/lib/i18n/messages'; +import { Button } from '~/ui/components/core/button'; +import { Separator } from '~/ui/components/core/separator'; +import { toast } from '~/ui/components/core/sonner'; + +export const Route = createFileRoute('/app/')({ + component: RouteComponent, +}); + +function RouteComponent() { + const context = Route.useRouteContext(); + const navigate = useNavigate(); + + async function handleSignOut() { + const { error } = await authClient.signOut(); + + if (error) { + toast.error(m.auth_sign_out_error_fail(), { + description: error.message, + }); + return; + } + + toast.success(m.auth_sign_out_success_title(), { + description: m.auth_sign_out_success_description(), + }); + navigate({ to: '/' }); + } + + return ( +
+
+

+ 👋🏻 {m.common_greeting_name({ name: context.user.name })} +

+ + + +
+ + +
+
+
+ ); +} diff --git a/src/routes/app/layout.tsx b/src/routes/app/layout.tsx new file mode 100644 index 0000000..a95ca81 --- /dev/null +++ b/src/routes/app/layout.tsx @@ -0,0 +1,17 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router'; + +import { authUserGuard } from '~/modules/auth/auth.utils'; + +export const Route = createFileRoute('/app')({ + beforeLoad: async ({ location }) => { + const user = await authUserGuard({ + redirectBack: location.pathname, + }); + return { user }; + }, + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/src/routes/auth/index.tsx b/src/routes/auth/index.tsx new file mode 100644 index 0000000..239494b --- /dev/null +++ b/src/routes/auth/index.tsx @@ -0,0 +1,87 @@ +import { Link, createFileRoute } from '@tanstack/react-router'; +import { ChevronRightIcon, LogInIcon, UserPlusIcon } from 'lucide-react'; + +import { m } from '~/lib/i18n/messages'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '~/ui/components/core/card'; + +export const Route = createFileRoute('/auth/')({ + component: RouteComponent, +}); + +function RouteComponent() { + const searchParams = Route.useSearch(); + + return ( +
+
+

+ {m.auth_get_started_title()} +

+

+ {m.auth_get_started_description()} +

+
+ +
    +
  • + + +
    + +
    + + + + + {m.auth_get_started_signup_title()} + + + + {m.auth_get_started_signup_description()} + + + + +
    +
    +
  • +
  • + + +
    + +
    + + + + + {m.auth_get_started_signin_title()} + + + + {m.auth_get_started_signin_description()} + + + + +
    +
    +
  • +
+
+ ); +} diff --git a/src/routes/auth/layout.tsx b/src/routes/auth/layout.tsx new file mode 100644 index 0000000..5430a60 --- /dev/null +++ b/src/routes/auth/layout.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; + +import { authSearchParamsSchema } from '~/modules/auth/auth.schema'; +import { authGuestGuard } from '~/modules/auth/auth.utils'; + +export const Route = createFileRoute('/auth')({ + validateSearch: zodValidator(authSearchParamsSchema), + beforeLoad: async () => await authGuestGuard(), + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ +
+
+ ); +} diff --git a/src/routes/auth/sign-in.tsx b/src/routes/auth/sign-in.tsx new file mode 100644 index 0000000..aec45b9 --- /dev/null +++ b/src/routes/auth/sign-in.tsx @@ -0,0 +1,38 @@ +import { createFileRoute, Link } from '@tanstack/react-router'; + +import { AuthSignInForm } from '~/features/auth-sign-in/auth-sign-in.form'; +import { m } from '~/lib/i18n/messages'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '~/ui/components/core/card'; +export const Route = createFileRoute('/auth/sign-in')({ + component: RouteComponent, +}); +function RouteComponent() { + const searchParams = Route.useSearch(); + + return ( + + + {m.auth_sign_in_title()} + {m.auth_sign_in_description()} + + + + + +

+ {m.auth_sign_in_no_account()}{' '} + + {m.auth_sign_in_sign_up_link()} + +

+
+
+ ); +} diff --git a/src/routes/auth/sign-up.tsx b/src/routes/auth/sign-up.tsx new file mode 100644 index 0000000..46420b2 --- /dev/null +++ b/src/routes/auth/sign-up.tsx @@ -0,0 +1,40 @@ +import { createFileRoute, Link } from '@tanstack/react-router'; + +import { AuthSignUpForm } from '~/features/auth-sign-up/auth-sign-up.form'; +import { m } from '~/lib/i18n/messages'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '~/ui/components/core/card'; + +export const Route = createFileRoute('/auth/sign-up')({ + component: RouteComponent, +}); + +function RouteComponent() { + const searchParams = Route.useSearch(); + + return ( + + + {m.auth_sign_up_title()} + {m.auth_sign_up_description()} + + + + + +

+ {m.auth_sign_up_already_have_account()}{' '} + + {m.auth_sign_up_sign_in_link()} + +

+
+
+ ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 1a13482..5926d26 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,12 +1,18 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, Link } from '@tanstack/react-router'; +import { authClient } from '~/lib/auth/client'; import { m } from '~/lib/i18n/messages'; +import { Button } from '~/ui/components/core/button'; +import { Skeleton } from '~/ui/components/core/skeleton'; export const Route = createFileRoute('/')({ component: HomePage, }); function HomePage() { + const { data: session, isPending: isPendingSession } = + authClient.useSession(); + return (
@@ -19,6 +25,22 @@ function HomePage() { git@github.com:devsantara/kit.git + +
+ {isPendingSession ? ( + + ) : ( + + )} +
);