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 (
+
+
+
+ );
+}
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_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 (
);
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({
-
-
-
+
+
+