From 0decd3099425971c394dedb953218fb9d057352a Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sun, 18 Jan 2026 14:40:33 +0700 Subject: [PATCH 01/11] feat(auth): setup authentication with better-auth --- .env.example | 4 + alchemy.run.ts | 5 + package.json | 1 + pnpm-lock.yaml | 180 +++++++++ src/lib/auth/client.ts | 3 + src/lib/auth/server.ts | 48 +++ .../migrations/0000_create_auth_tables.sql | 54 +++ .../migrations/meta/0000_snapshot.json | 377 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 13 + src/lib/database/schema/auth.db.ts | 110 +++++ src/lib/env/server.ts | 6 +- src/routeTree.gen.ts | 24 +- src/routes/api/auth.$.ts | 16 + 13 files changed, 836 insertions(+), 5 deletions(-) create mode 100644 src/lib/auth/client.ts create mode 100644 src/lib/auth/server.ts create mode 100644 src/lib/database/migrations/0000_create_auth_tables.sql create mode 100644 src/lib/database/migrations/meta/0000_snapshot.json create mode 100644 src/lib/database/migrations/meta/_journal.json create mode 100644 src/lib/database/schema/auth.db.ts create mode 100644 src/routes/api/auth.$.ts diff --git a/.env.example b/.env.example index 3557091..d3cf3f1 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,7 @@ 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=<***> diff --git a/alchemy.run.ts b/alchemy.run.ts index 2c69515..c9805ae 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -17,9 +17,11 @@ 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); function isProductionStage(scope: Scope) { return scope.stage === 'production'; @@ -57,7 +59,10 @@ export const worker = await TanStackStart('website', { domains: isProductionStage(app) ? [alchemyEnv.HOSTNAME] : undefined, placement: isProductionStage(app) ? { mode: 'smart' } : undefined, bindings: { + // Services DATABASE: database, + // Environment variables + AUTH_SECRET: AUTH_SECRET, }, }); diff --git a/package.json b/package.json index bb34b4e..2a4e2dc 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/react-router": "^1.144.0", "@tanstack/react-start": "^1.145.5", + "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 4165d7b..f139190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ 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)) + 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)(kysely@0.27.6))(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 @@ -507,6 +510,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'} @@ -1602,6 +1626,14 @@ packages: '@types/react': '>=16' react: '>=16' + '@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==} @@ -3576,6 +3608,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'} @@ -4691,6 +4793,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-sha256@0.11.1: resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} @@ -4732,6 +4837,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==} @@ -5005,6 +5114,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==} @@ -5485,6 +5598,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==} @@ -6600,6 +6716,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.27.6)(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.27.6 + 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.27.6)(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 @@ -7416,6 +7553,10 @@ snapshots: '@types/react': 19.2.7 react: 19.2.3 + '@noble/ciphers@2.1.1': {} + + '@noble/hashes@2.0.1': {} + '@nodeutils/defaults-deep@1.1.0': dependencies: lodash: 4.17.21 @@ -9576,6 +9717,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)(kysely@0.27.6))(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.27.6)(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)(kysely@0.27.6) + 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: {} @@ -10661,6 +10833,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-sha256@0.11.1: {} js-tokens@4.0.0: {} @@ -10690,6 +10864,8 @@ snapshots: kysely@0.27.6: {} + kysely@0.28.9: {} + launch-editor@2.12.0: dependencies: picocolors: 1.1.1 @@ -10917,6 +11093,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.1.0: {} + neo-async@2.6.2: {} netmask@2.0.2: {} @@ -11475,6 +11653,8 @@ snapshots: seroval@1.4.2: {} + set-cookie-parser@2.7.2: {} + setimmediate@1.0.5: {} sharp@0.33.5: 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/server.ts b/src/lib/auth/server.ts new file mode 100644 index 0000000..515118c --- /dev/null +++ b/src/lib/auth/server.ts @@ -0,0 +1,48 @@ +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { betterAuth } from 'better-auth/minimal'; +import { tanstackStartCookies } from 'better-auth/tanstack-start'; + +import { getDatabase } from '~/lib/database'; +import { + accountTable, + sessionTable, + userTable, + verificationTable, +} from '~/lib/database/schema/auth.db'; +import { serverEnv } from '~/lib/env/server'; + +export const authServer = betterAuth({ + secret: serverEnv.AUTH_SECRET, + database: drizzleAdapter(getDatabase(), { + provider: 'sqlite', + usePlural: true, + schema: { + users: userTable, + sessions: sessionTable, + accounts: accountTable, + verifications: verificationTable, + }, + }), + plugins: [tanstackStartCookies()], + emailAndPassword: { + enabled: true, + autoSignIn: false, + minPasswordLength: 6, + }, + 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', + database: { + generateId: 'uuid', + }, + }, +}); 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..87d9f93 100644 --- a/src/lib/env/server.ts +++ b/src/lib/env/server.ts @@ -1,9 +1,11 @@ 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(), + }, runtimeEnv: process.env, emptyStringAsUndefined: true, }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1a07a3f..f31f44c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,33 +10,43 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ + id: '/api/auth/$', + path: '/api/auth/$', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/api/auth/$' + id: '__root__' | '/' | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ApiAuthSplatRoute: typeof ApiAuthSplatRoute } declare module '@tanstack/react-router' { @@ -48,11 +58,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/api/auth/$': { + id: '/api/auth/$' + path: '/api/auth/$' + fullPath: '/api/auth/$' + preLoaderRoute: typeof ApiAuthSplatRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + 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); + }, + }, + }, +}); From b5f8a8fab20ac618bfe5bac5e2412cc7b5d32f26 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Mon, 26 Jan 2026 10:17:54 +0700 Subject: [PATCH 02/11] feat(auth): adjust min password length --- src/lib/auth/constant.ts | 1 + src/lib/auth/server.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/lib/auth/constant.ts diff --git a/src/lib/auth/constant.ts b/src/lib/auth/constant.ts new file mode 100644 index 0000000..a946ff4 --- /dev/null +++ b/src/lib/auth/constant.ts @@ -0,0 +1 @@ +export const AUTH_MIN_PASSWORD_LENGTH = 8; diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts index 515118c..a93e719 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -2,6 +2,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { betterAuth } from 'better-auth/minimal'; import { tanstackStartCookies } from 'better-auth/tanstack-start'; +import { AUTH_MIN_PASSWORD_LENGTH } from '~/lib/auth/constant'; import { getDatabase } from '~/lib/database'; import { accountTable, @@ -27,7 +28,7 @@ export const authServer = betterAuth({ emailAndPassword: { enabled: true, autoSignIn: false, - minPasswordLength: 6, + minPasswordLength: AUTH_MIN_PASSWORD_LENGTH, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days From d9e66e29f774b30b490ba5c632bc23ad3bd8bf5d Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Mon, 26 Jan 2026 10:18:34 +0700 Subject: [PATCH 03/11] feat(form): add destructive error alert border --- src/lib/form/components/form-error.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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}} From d8d1ed0a6d213122e28f85ce4f488afa2a5ab977 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 27 Jan 2026 13:09:28 +0700 Subject: [PATCH 04/11] fix: broken lockfile --- pnpm-lock.yaml | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b454ed0..8c19e45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: 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)) 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)(kysely@0.27.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) + 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)(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 +59,7 @@ importers: version: 4.1.0 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.27.6) + version: 0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.28.9) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) @@ -171,7 +171,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)))(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)))(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 @@ -2942,6 +2942,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: @@ -6786,20 +6789,20 @@ 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.27.6)(nanostores@1.1.0)': + '@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.27.6 + 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.27.6)(nanostores@1.1.0) + '@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 @@ -9089,6 +9092,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) @@ -9767,7 +9772,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)))(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)))(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) @@ -9777,7 +9782,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)(kysely@0.27.6) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.28.9) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -9927,9 +9932,9 @@ 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)(kysely@0.27.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10): + 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)(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.27.6)(nanostores@1.1.0) + '@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 @@ -9944,7 +9949,7 @@ snapshots: 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)(kysely@0.27.6) + drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.28.9) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) solid-js: 1.9.10 @@ -10420,10 +10425,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.27.6): + drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(kysely@0.28.9): optionalDependencies: '@cloudflare/workers-types': 4.20260103.0 - kysely: 0.27.6 + kysely: 0.28.9 dunder-proto@1.0.1: dependencies: From b5773378e7b697dd4f17db2cb9e4b67cc77513a2 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Wed, 28 Jan 2026 16:18:29 +0700 Subject: [PATCH 05/11] feat(i18n): add messages for auth field and errors --- messages/en.json | 78 ++++++++++++++++++++++++++++++++++++++++++++- messages/id.json | 78 ++++++++++++++++++++++++++++++++++++++++++++- messages/zh-CN.json | 78 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 231 insertions(+), 3 deletions(-) diff --git a/messages/en.json b/messages/en.json index 8128340..1b24f51 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,5 +5,81 @@ "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.", + + "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_signup_title": "Create your account", + "auth_signup_description": "Fill out the form below to create your account", + "auth_signup_action": "Create Account", + "auth_signup_already_have_account": "Already have an account?", + "auth_signup_sign_in_link": "Sign in", + "auth_signup_error_fail": "Sign up failed", + "auth_signup_success_title": "Account created successfully", + "auth_signup_success_description": "Please sign in to continue.", + + "auth_signin_title": "Sign in to your account", + "auth_signin_description": "Please enter your details to continue.", + "auth_signin_action": "Sign in", + "auth_signin_fail": "Sign in failed", + "auth_signin_success_title": "Welcome back!", + "auth_signin_success_description": "We're happy to see you again.", + "auth_signin_no_account": "Don't have an account?", + "auth_signin_sign_up_link": "Sign up", + + "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..e8ece1b 100644 --- a/messages/id.json +++ b/messages/id.json @@ -5,5 +5,81 @@ "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.", + + "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_signup_title": "Buat akun Anda", + "auth_signup_description": "Isi formulir di bawah untuk membuat akun Anda", + "auth_signup_action": "Buat Akun", + "auth_signup_already_have_account": "Sudah punya akun?", + "auth_signup_sign_in_link": "Masuk", + "auth_signup_error_fail": "Pendaftaran gagal", + "auth_signup_success_title": "Akun berhasil dibuat", + "auth_signup_success_description": "Silakan masuk untuk melanjutkan.", + + "auth_signin_title": "Masuk ke akun Anda", + "auth_signin_description": "Silakan masukkan detail Anda untuk melanjutkan.", + "auth_signin_action": "Masuk", + "auth_signin_fail": "Gagal masuk", + "auth_signin_success_title": "Selamat datang kembali!", + "auth_signin_success_description": "Senang melihat Anda kembali.", + "auth_signin_no_account": "Belum punya akun?", + "auth_signin_sign_up_link": "Daftar", + + "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..f0e0a95 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -5,5 +5,81 @@ "common_error_something_went_wrong": "出现了一些问题", "common_error_form_validation_title": "表单存在问题", - "common_error_form_validation_description": "请检查表单并更正后继续。" + "common_error_form_validation_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_signup_title": "创建你的账户", + "auth_signup_description": "请填写下方表单以创建你的账户", + "auth_signup_action": "创建账户", + "auth_signup_already_have_account": "已有账户?", + "auth_signup_sign_in_link": "登录", + "auth_signup_error_fail": "注册失败", + "auth_signup_success_title": "账户创建成功", + "auth_signup_success_description": "请登录以继续。", + + "auth_signin_title": "登录到你的账户", + "auth_signin_description": "请输入你的信息以继续。", + "auth_signin_action": "登录", + "auth_signin_fail": "登录失败", + "auth_signin_success_title": "欢迎回来!", + "auth_signin_success_description": "很高兴再次见到你。", + "auth_signin_no_account": "还没有账户?", + "auth_signin_sign_up_link": "注册", + + "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": "字段为必填项" } From 4efe40f06821ab455442b744bb1fec31971aff69 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Wed, 28 Jan 2026 16:56:52 +0700 Subject: [PATCH 06/11] feat(auth): add error message map translation --- src/lib/auth/errors.ts | 62 ++++++++++++++++++++++++++++++++++++++++++ src/lib/auth/server.ts | 19 +++++++++++++ src/lib/auth/types.ts | 7 +++++ 3 files changed, 88 insertions(+) create mode 100644 src/lib/auth/errors.ts create mode 100644 src/lib/auth/types.ts 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 index a93e719..e1ef3ef 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -1,8 +1,10 @@ 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 { AUTH_MIN_PASSWORD_LENGTH } from '~/lib/auth/constant'; +import { getAuthErrorMessage } from '~/lib/auth/errors'; import { getDatabase } from '~/lib/database'; import { accountTable, @@ -46,4 +48,21 @@ export const authServer = betterAuth({ 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 +>; From 4e5bb17712601297f9fa15d2120e89af35d1ee41 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 30 Jan 2026 10:52:17 +0700 Subject: [PATCH 07/11] feat(auth): add authentication mechanism --- messages/en.json | 48 ++++-- messages/id.json | 48 ++++-- messages/zh-CN.json | 48 ++++-- package.json | 1 + pnpm-lock.yaml | 15 ++ .../auth-sign-in/auth-sign-in.form.tsx | 113 +++++++++++++ .../auth-sign-in/auth-sign-in.schema.ts | 15 ++ .../auth-sign-up/auth-sign-up.form.tsx | 159 ++++++++++++++++++ .../auth-sign-up/auth-sign-up.schema.ts | 22 +++ src/modules/auth/auth.fn.ts | 18 ++ src/modules/auth/auth.schema.ts | 11 ++ .../auth/components/auth-social-button.tsx | 74 ++++++++ .../components/auth-social-provider-icon.tsx | 107 ++++++++++++ src/routeTree.gen.ts | 149 +++++++++++++++- src/routes/app/index.tsx | 59 +++++++ src/routes/app/layout.tsx | 22 +++ src/routes/auth/index.tsx | 82 +++++++++ src/routes/auth/layout.tsx | 27 +++ src/routes/auth/sign-in.tsx | 38 +++++ src/routes/auth/sign-up.tsx | 40 +++++ src/routes/index.tsx | 29 +++- 21 files changed, 1073 insertions(+), 52 deletions(-) create mode 100644 src/features/auth-sign-in/auth-sign-in.form.tsx create mode 100644 src/features/auth-sign-in/auth-sign-in.schema.ts create mode 100644 src/features/auth-sign-up/auth-sign-up.form.tsx create mode 100644 src/features/auth-sign-up/auth-sign-up.schema.ts create mode 100644 src/modules/auth/auth.fn.ts create mode 100644 src/modules/auth/auth.schema.ts create mode 100644 src/modules/auth/components/auth-social-button.tsx create mode 100644 src/modules/auth/components/auth-social-provider-icon.tsx create mode 100644 src/routes/app/index.tsx create mode 100644 src/routes/app/layout.tsx create mode 100644 src/routes/auth/index.tsx create mode 100644 src/routes/auth/layout.tsx create mode 100644 src/routes/auth/sign-in.tsx create mode 100644 src/routes/auth/sign-up.tsx diff --git a/messages/en.json b/messages/en.json index 1b24f51..5234171 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7,6 +7,17 @@ "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_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", @@ -21,23 +32,28 @@ "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_signup_title": "Create your account", - "auth_signup_description": "Fill out the form below to create your account", - "auth_signup_action": "Create Account", - "auth_signup_already_have_account": "Already have an account?", - "auth_signup_sign_in_link": "Sign in", - "auth_signup_error_fail": "Sign up failed", - "auth_signup_success_title": "Account created successfully", - "auth_signup_success_description": "Please sign in to continue.", + "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_signin_title": "Sign in to your account", - "auth_signin_description": "Please enter your details to continue.", - "auth_signin_action": "Sign in", - "auth_signin_fail": "Sign in failed", - "auth_signin_success_title": "Welcome back!", - "auth_signin_success_description": "We're happy to see you again.", - "auth_signin_no_account": "Don't have an account?", - "auth_signin_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", diff --git a/messages/id.json b/messages/id.json index e8ece1b..d55d30b 100644 --- a/messages/id.json +++ b/messages/id.json @@ -7,6 +7,17 @@ "common_error_form_validation_title": "Ada kesalahan pada formulir", "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", @@ -21,23 +32,28 @@ "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_signup_title": "Buat akun Anda", - "auth_signup_description": "Isi formulir di bawah untuk membuat akun Anda", - "auth_signup_action": "Buat Akun", - "auth_signup_already_have_account": "Sudah punya akun?", - "auth_signup_sign_in_link": "Masuk", - "auth_signup_error_fail": "Pendaftaran gagal", - "auth_signup_success_title": "Akun berhasil dibuat", - "auth_signup_success_description": "Silakan masuk untuk melanjutkan.", + "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_signin_title": "Masuk ke akun Anda", - "auth_signin_description": "Silakan masukkan detail Anda untuk melanjutkan.", - "auth_signin_action": "Masuk", - "auth_signin_fail": "Gagal masuk", - "auth_signin_success_title": "Selamat datang kembali!", - "auth_signin_success_description": "Senang melihat Anda kembali.", - "auth_signin_no_account": "Belum punya akun?", - "auth_signin_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", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index f0e0a95..f751d56 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -7,6 +7,17 @@ "common_error_form_validation_title": "表单存在问题", "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": "或", @@ -21,23 +32,28 @@ "auth_field_error_password_min_length": "密码至少需要 {min} 个字符", "auth_field_error_confirm_password_not_match": "密码与确认密码不一致", - "auth_signup_title": "创建你的账户", - "auth_signup_description": "请填写下方表单以创建你的账户", - "auth_signup_action": "创建账户", - "auth_signup_already_have_account": "已有账户?", - "auth_signup_sign_in_link": "登录", - "auth_signup_error_fail": "注册失败", - "auth_signup_success_title": "账户创建成功", - "auth_signup_success_description": "请登录以继续。", + "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_signin_title": "登录到你的账户", - "auth_signin_description": "请输入你的信息以继续。", - "auth_signin_action": "登录", - "auth_signin_fail": "登录失败", - "auth_signin_success_title": "欢迎回来!", - "auth_signin_success_description": "很高兴再次见到你。", - "auth_signin_no_account": "还没有账户?", - "auth_signin_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": "创建用户失败", diff --git a/package.json b/package.json index 76e3b1a..0c11a15 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c19e45..e60b3d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ 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)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) @@ -3338,6 +3341,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'} @@ -9596,6 +9606,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 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..ad087bc --- /dev/null +++ b/src/features/auth-sign-in/auth-sign-in.form.tsx @@ -0,0 +1,113 @@ +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..da39887 --- /dev/null +++ b/src/features/auth-sign-up/auth-sign-up.form.tsx @@ -0,0 +1,159 @@ +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/modules/auth/auth.fn.ts b/src/modules/auth/auth.fn.ts new file mode 100644 index 0000000..98ff3d4 --- /dev/null +++ b/src/modules/auth/auth.fn.ts @@ -0,0 +1,18 @@ +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. + * 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, + }); + return authSession?.user ?? null; + }, +); 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/components/auth-social-button.tsx b/src/modules/auth/components/auth-social-button.tsx new file mode 100644 index 0000000..7a36b5e --- /dev/null +++ b/src/modules/auth/components/auth-social-button.tsx @@ -0,0 +1,74 @@ +import { useRouter } from '@tanstack/react-router'; + +import { authClient } from '~/lib/auth/client'; +import { m } from '~/lib/i18n/messages'; +import { + AppleIcon, + 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', + }, + apple: { + icon: , + name: 'Apple', + }, + 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 router = useRouter(); + + const socialProvider = SOCIAL_PROVIDERS[provider]; + + async function handleSocialSignIn() { + const { error } = await authClient.signIn.social({ provider }); + + 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(), + }); + await router.navigate({ to: redirectBack ?? '/app' }); + } + + 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..2925b57 --- /dev/null +++ b/src/modules/auth/components/auth-social-provider-icon.tsx @@ -0,0 +1,107 @@ +export interface ProviderIconProps extends React.ComponentProps<'svg'> {} + +export function AppleIcon({ className, ...props }: ProviderIconProps) { + return ( + + + + ); +} + +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 f31f44c..1f0a0f5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,14 +9,50 @@ // 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/$', @@ -25,32 +61,81 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ 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: '/' | '/api/auth/$' + fullPaths: + | '/' + | '/app' + | '/auth' + | '/auth/sign-in' + | '/auth/sign-up' + | '/app/' + | '/auth/' + | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/auth/$' - id: '__root__' | '/' | '/api/auth/$' + 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: '/' @@ -58,6 +143,34 @@ 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/$' @@ -68,8 +181,38 @@ declare module '@tanstack/react-router' { } } +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 diff --git a/src/routes/app/index.tsx b/src/routes/app/index.tsx new file mode 100644 index 0000000..8e06571 --- /dev/null +++ b/src/routes/app/index.tsx @@ -0,0 +1,59 @@ +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 })} +

+ + {context.user.email} + + + + +
+ + +
+
+
+ ); +} diff --git a/src/routes/app/layout.tsx b/src/routes/app/layout.tsx new file mode 100644 index 0000000..9bc78f8 --- /dev/null +++ b/src/routes/app/layout.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; + +import { getCurrentUserFn } from '~/modules/auth/auth.fn'; + +export const Route = createFileRoute('/app')({ + beforeLoad: async ({ location }) => { + const user = await getCurrentUserFn(); + const isAuthenticated = user !== null; + if (!isAuthenticated) { + throw redirect({ + to: '/auth', + search: { 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..d38c5af --- /dev/null +++ b/src/routes/auth/index.tsx @@ -0,0 +1,82 @@ +import { Link, createFileRoute } from '@tanstack/react-router'; +import { ChevronRight, LogIn, UserPlus } from 'lucide-react'; + +import { m } from '~/lib/i18n/messages'; +import { + Card, + 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..8feece6 --- /dev/null +++ b/src/routes/auth/layout.tsx @@ -0,0 +1,27 @@ +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; + +import { getCurrentUserFn } from '~/modules/auth/auth.fn'; +import { authSearchParamsSchema } from '~/modules/auth/auth.schema'; + +export const Route = createFileRoute('/auth')({ + validateSearch: zodValidator(authSearchParamsSchema), + beforeLoad: async () => { + const user = await getCurrentUserFn(); + const isAuthenticated = user !== null; + if (isAuthenticated) { + throw redirect({ to: '/app' }); + } + }, + 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..260624a 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,27 @@ function HomePage() { git@github.com:devsantara/kit.git + +
+ {isPendingSession ? ( + + ) : ( + + )} +
); From 22f2d4b4627ce6689addbe89a685291149815e9d Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 30 Jan 2026 11:55:11 +0700 Subject: [PATCH 08/11] feat(auth): refine layout --- src/routes/auth/index.tsx | 85 +++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/src/routes/auth/index.tsx b/src/routes/auth/index.tsx index d38c5af..21bd94f 100644 --- a/src/routes/auth/index.tsx +++ b/src/routes/auth/index.tsx @@ -1,9 +1,10 @@ import { Link, createFileRoute } from '@tanstack/react-router'; -import { ChevronRight, LogIn, UserPlus } from 'lucide-react'; +import { ChevronRight, LogInIcon, UserPlusIcon } from 'lucide-react'; import { m } from '~/lib/i18n/messages'; import { Card, + CardContent, CardDescription, CardHeader, CardTitle, @@ -27,53 +28,57 @@ function RouteComponent() {

-
    +
    • - -
      -
      - -
      - + +
      +
      - - - {m.auth_get_started_signup_title()} - - - - {m.auth_get_started_signup_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()} - - + + + + + {m.auth_get_started_signin_title()} + + + + {m.auth_get_started_signin_description()} + + + + +
    From 2b3cf92f36c0873423f0468dd1f9eb0a1aff26a4 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 30 Jan 2026 11:55:37 +0700 Subject: [PATCH 09/11] feat(app): refine layout --- src/routes/app/index.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/routes/app/index.tsx b/src/routes/app/index.tsx index 8e06571..427686b 100644 --- a/src/routes/app/index.tsx +++ b/src/routes/app/index.tsx @@ -33,13 +33,10 @@ function RouteComponent() { return (
    -
    -

    +
    +

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

    - - {context.user.email} - From 4fe5f5a43058c55c4bc547b98cb30328641c0dce Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Fri, 30 Jan 2026 16:46:06 +0700 Subject: [PATCH 10/11] refactor(import): use icon suffix for icon import --- src/routes/auth/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/auth/index.tsx b/src/routes/auth/index.tsx index 21bd94f..239494b 100644 --- a/src/routes/auth/index.tsx +++ b/src/routes/auth/index.tsx @@ -1,5 +1,5 @@ import { Link, createFileRoute } from '@tanstack/react-router'; -import { ChevronRight, LogInIcon, UserPlusIcon } from 'lucide-react'; +import { ChevronRightIcon, LogInIcon, UserPlusIcon } from 'lucide-react'; import { m } from '~/lib/i18n/messages'; import { @@ -51,7 +51,7 @@ function RouteComponent() { - + @@ -77,7 +77,7 @@ function RouteComponent() { - + From 6a4857d3c7278d6d69689c5a5f6edb1e4998f3b1 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Tue, 10 Feb 2026 13:10:15 +0700 Subject: [PATCH 11/11] feat(auth): disable social provider for now --- .../auth-sign-in/auth-sign-in.form.tsx | 18 +++++++++++++++--- .../auth-sign-up/auth-sign-up.form.tsx | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/features/auth-sign-in/auth-sign-in.form.tsx b/src/features/auth-sign-in/auth-sign-in.form.tsx index ad087bc..b626e5b 100644 --- a/src/features/auth-sign-in/auth-sign-in.form.tsx +++ b/src/features/auth-sign-in/auth-sign-in.form.tsx @@ -101,9 +101,21 @@ export function AuthSignInForm({ - - - + + + diff --git a/src/features/auth-sign-up/auth-sign-up.form.tsx b/src/features/auth-sign-up/auth-sign-up.form.tsx index da39887..0ea2791 100644 --- a/src/features/auth-sign-up/auth-sign-up.form.tsx +++ b/src/features/auth-sign-up/auth-sign-up.form.tsx @@ -147,9 +147,21 @@ export function AuthSignUpForm({ - - - + + +