fix: auth login flow, Next.js 16 upgrade, ESM/CJS compatibility

- Upgrade Next.js 15 → 16.1 and rename middleware.ts → proxy.ts
- Fix ESM/CJS compatibility: remove "type": "module" from config/shared packages, switch to commonjs
- Fix Better Auth integration: text IDs for sessions/accounts/verifications, wildcard route @All("**"), proper password hash for admin seed
- Fix auth-client to use window.location.origin instead of hardcoded localhost:4000
- Fix api-client, forgot-password, reset-password to use relative /api/ URLs (via Next.js proxy)
- Fix TimeoutInterceptor DI by using useValue instead of useClass
- Add Playwright E2E login tests for localhost and v2.sase.tr
- Add missing UI components: vin-input, vehicle-card, category-tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-12 02:54:15 +00:00
parent 56a3c8bfaa
commit ffa9781eb9
24 changed files with 550 additions and 75 deletions

View File

@@ -70,7 +70,7 @@ import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
{ provide: APP_INTERCEPTOR, useValue: new TimeoutInterceptor(30000) },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
],
})

View File

@@ -2,10 +2,12 @@ import { All, Controller, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
import { getAuth } from "./auth";
import { toNodeHandler } from "better-auth/node";
import { Public } from "../common/decorators/public.decorator";
@Controller("auth")
export class AuthController {
@All("*path")
@Public()
@All("**")
async handleAuth(@Req() req: Request, @Res() res: Response) {
const auth = getAuth();
const handler = toNodeHandler(auth);

View File

@@ -56,7 +56,10 @@ export function createAuth(databaseUrl: string, secret: string, baseUrl: string)
},
},
},
trustedOrigins: (process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
trustedOrigins: [
...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
"http://localhost:4000",
],
});
return authInstance;

View File

@@ -36,7 +36,7 @@ export const users = pgTable(
export const sessions = pgTable(
"sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
id: text("id").primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -54,7 +54,7 @@ export const sessions = pgTable(
export const accounts = pgTable(
"accounts",
{
id: uuid("id").primaryKey().defaultRandom(),
id: text("id").primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -77,7 +77,7 @@ export const accounts = pgTable(
export const verifications = pgTable(
"verifications",
{
id: uuid("id").primaryKey().defaultRandom(),
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),

View File

@@ -74,8 +74,7 @@ async function seed() {
.returning();
if (adminUser) {
// Create credential account for admin (password: Admin123!)
// In production, use Better Auth's proper password hashing
// Password: Sase2026 (hashed with Better Auth's hashPassword)
await db
.insert(accounts)
.values({
@@ -83,7 +82,7 @@ async function seed() {
accountId: adminUser.id,
providerId: "credential",
password:
"$2a$10$placeholder_hash_replace_with_better_auth",
"5a51a76d4092d38c578b0b728aecac18:bb7877ca1148573e0980c7a825a10cda592fd01a13e7262811a8cee3d6174632ebe5392539b31d5c1f0de69620961d447cb9f929414cb1ec30ff66037a9c38f5",
})
.onConflictDoNothing();
}
@@ -91,7 +90,7 @@ async function seed() {
console.log("Seed completed!");
console.log(`- ${BRANDS_DATA.length} brands`);
console.log(`- ${PLANS_DATA.length} plans`);
console.log("- 1 admin user (admin@sase.tr)");
console.log("- 1 admin user (admin@sase.tr / Sase2026)");
await client.end();
process.exit(0);

View File

@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
test("localhost:3000 prod - login debug", async ({ page }) => {
const allRequests: string[] = [];
page.on("request", (req) => {
allRequests.push(`${req.method()} ${req.url()}`);
});
const consoleMessages: string[] = [];
page.on("console", (msg) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
});
await page.goto(`${BASE_URL}/login`);
await page.fill('input[type="email"]', "admin@sase.tr");
await page.fill('input[type="password"]', "Sase2026");
await page.click('button[type="submit"]');
await page.waitForTimeout(5000);
console.log("\n=== ALL REQUESTS ===");
for (const r of allRequests) {
if (r.includes("/api/") || r.includes("/auth/")) console.log(r);
}
const cookies = await page.context().cookies();
console.log("\n=== COOKIES ===");
for (const c of cookies) {
console.log(` ${c.name} = ${c.value.substring(0, 20)}... (domain: ${c.domain}, secure: ${c.secure})`);
}
console.log("\n=== CONSOLE ===");
for (const m of consoleMessages) {
if (m.includes("error") || m.includes("Error") || m.includes("auth") || m.includes("fetch")) console.log(m);
}
const currentUrl = page.url();
console.log("\nCurrent URL:", currentUrl);
expect(currentUrl).toContain("/dashboard");
});

View File

@@ -0,0 +1,95 @@
import { test, expect } from "@playwright/test";
const BASE_URL = "https://v2.sase.tr";
test("v2.sase.tr - should login with admin@sase.tr / Sase2026", async ({ page }) => {
// Capture ALL network requests/responses for debugging
const allRequests: string[] = [];
page.on("request", (req) => {
if (req.url().includes("/auth/") || req.url().includes("/api/")) {
allRequests.push(`${req.method()} ${req.url()}`);
}
});
const allResponses: { url: string; status: number; headers: Record<string, string>; body: string }[] = [];
page.on("response", async (res) => {
if (res.url().includes("/auth/")) {
let body = "";
try {
body = await res.text();
} catch {
body = "(could not read body)";
}
const headers = res.headers();
allResponses.push({
url: res.url(),
status: res.status(),
headers: {
"set-cookie": headers["set-cookie"] || "(none)",
"content-type": headers["content-type"] || "(none)",
"access-control-allow-origin": headers["access-control-allow-origin"] || "(none)",
"access-control-allow-credentials": headers["access-control-allow-credentials"] || "(none)",
},
body: body.substring(0, 500),
});
}
});
// Capture console errors
const consoleMessages: string[] = [];
page.on("console", (msg) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
});
// Navigate to login page
await page.goto(`${BASE_URL}/login`);
console.log("Page loaded:", page.url());
// Fill email
await page.fill('input[type="email"]', "admin@sase.tr");
// Fill password
await page.fill('input[type="password"]', "Sase2026");
// Click submit
await page.click('button[type="submit"]');
// Wait for navigation or error
await page.waitForTimeout(8000);
// Log debug info
console.log("\n=== ALL API REQUESTS ===");
for (const r of allRequests) console.log(r);
console.log("\n=== AUTH RESPONSES (with headers) ===");
for (const r of allResponses) {
console.log(`${r.status} ${r.url}`);
console.log(` Headers:`, JSON.stringify(r.headers, null, 2));
console.log(` Body: ${r.body}\n`);
}
// Check cookies
const cookies = await page.context().cookies();
console.log("\n=== BROWSER COOKIES ===");
for (const c of cookies) {
console.log(` ${c.name} = ${c.value.substring(0, 30)}... (domain: ${c.domain}, secure: ${c.secure}, sameSite: ${c.sameSite}, path: ${c.path})`);
}
// Current URL
const currentUrl = page.url();
console.log("\nCurrent URL:", currentUrl);
// Check for error toast
const toast = page.locator('[data-sonner-toast]');
const toastCount = await toast.count();
if (toastCount > 0) {
const toastText = await toast.first().textContent();
console.log("Toast message:", toastText);
}
// Console messages
console.log("\n=== CONSOLE MESSAGES ===");
for (const m of consoleMessages) console.log(m);
// Verify login succeeded
expect(currentUrl).toContain("/dashboard");
});

View File

@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
test.describe("Login Flow", () => {
test("should load login page", async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
await expect(page.getByText("Giriş Yap", { exact: true }).first()).toBeVisible();
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
});
test("should login with admin@sase.tr / Sase2026", async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
// Fill email
await page.fill('input[type="email"]', "admin@sase.tr");
// Fill password
await page.fill('input[type="password"]', "Sase2026");
// Listen for network requests to debug
const requests: string[] = [];
page.on("request", (req) => {
if (req.url().includes("/auth/")) {
requests.push(`${req.method()} ${req.url()}`);
}
});
const responses: { url: string; status: number; body: string }[] = [];
page.on("response", async (res) => {
if (res.url().includes("/auth/")) {
let body = "";
try {
body = await res.text();
} catch {
body = "(could not read body)";
}
responses.push({ url: res.url(), status: res.status(), body: body.substring(0, 500) });
}
});
// Click submit
await page.click('button[type="submit"]');
// Wait for navigation or error
await page.waitForTimeout(5000);
// Log debug info
console.log("\n=== AUTH REQUESTS ===");
for (const r of requests) console.log(r);
console.log("\n=== AUTH RESPONSES ===");
for (const r of responses) console.log(`${r.status} ${r.url}\n Body: ${r.body}\n`);
// Check current URL
const currentUrl = page.url();
console.log("Current URL:", currentUrl);
// Check for error toast
const toast = page.locator('[data-sonner-toast]');
const toastCount = await toast.count();
if (toastCount > 0) {
const toastText = await toast.first().textContent();
console.log("Toast message:", toastText);
}
// Check console errors
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
// Verify login succeeded (should redirect to /dashboard/search)
expect(currentUrl).toContain("/dashboard");
});
});

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -19,7 +19,7 @@
"better-auth": "^1.2.0",
"clsx": "^2.1.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next": "^16.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.0",
@@ -28,6 +28,7 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",

View File

@@ -18,7 +18,7 @@ export default function ForgotPasswordPage() {
setLoading(true);
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api"}/auth/forget-password`, {
await fetch("/api/auth/forget-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, redirectTo: "/reset-password" }),

View File

@@ -21,7 +21,7 @@ function ResetPasswordForm() {
setLoading(true);
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api"}/auth/reset-password`, {
await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword: password, token }),

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronRight, ChevronDown, FolderOpen, Folder } from "lucide-react";
interface Category {
id: string;
name: string;
children?: Category[];
partCount?: number;
}
interface CategoryTreeProps {
categories: Category[];
vehicleId: string;
basePath?: string;
}
export function CategoryTree({ categories, vehicleId, basePath }: CategoryTreeProps) {
if (!categories || categories.length === 0) {
return (
<p className="py-4 text-center text-sm text-muted-foreground">
Kategori bulunamadi.
</p>
);
}
return (
<div className="space-y-1">
{categories.map((category) => (
<CategoryNode
key={category.id}
category={category}
vehicleId={vehicleId}
basePath={basePath}
level={0}
/>
))}
</div>
);
}
interface CategoryNodeProps {
category: Category;
vehicleId: string;
basePath?: string;
level: number;
}
function CategoryNode({ category, vehicleId, basePath, level }: CategoryNodeProps) {
const [expanded, setExpanded] = useState(false);
const hasChildren = category.children && category.children.length > 0;
const href = basePath
? `${basePath}/${category.id}`
: `/vehicles/${vehicleId}/categories/${category.id}`;
return (
<div>
<div
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="h-5 w-5" />
)}
{expanded ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
)}
<Link href={href} className="flex-1 truncate hover:underline">
{category.name}
</Link>
{category.partCount != null && category.partCount > 0 && (
<span className="text-xs text-muted-foreground">
{category.partCount}
</span>
)}
</div>
{hasChildren && expanded && (
<div>
{category.children!.map((child) => (
<CategoryNode
key={child.id}
category={child}
vehicleId={vehicleId}
basePath={basePath}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
interface VehicleCardProps {
id: string;
vin: string;
brandName: string;
model: string;
year?: number | string | null;
href?: string;
}
export function VehicleCard({ id, vin, brandName, model, year, href }: VehicleCardProps) {
const linkHref = href || `/dashboard/vehicles/${id}`;
return (
<Link href={linkHref}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{brandName} {model}
</CardTitle>
{year && <Badge variant="secondary">{year}</Badge>}
</div>
</CardHeader>
<CardContent>
<p className="font-mono text-sm text-muted-foreground">{vin}</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Search } from "lucide-react";
import { isValidVin } from "@sase/shared";
interface VinInputProps {
onSubmit: (vin: string) => void;
loading?: boolean;
error?: string | null;
}
export function VinInput({ onSubmit, loading, error }: VinInputProps) {
const [vin, setVin] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin)) return;
onSubmit(cleanVin);
}
const vinUpper = vin.toUpperCase().trim();
const isInvalid = vinUpper.length === 17 && !isValidVin(vinUpper);
return (
<div>
<form onSubmit={handleSubmit} className="flex gap-3">
<Input
placeholder="VIN numarasini girin (17 karakter)"
value={vin}
onChange={(e) => setVin(e.target.value.toUpperCase())}
maxLength={17}
className="font-mono text-lg tracking-wider"
/>
<Button type="submit" disabled={loading || vin.length !== 17 || isInvalid}>
{loading ? (
<span className="animate-spin">...</span>
) : (
<Search className="h-4 w-4" />
)}
Ara
</Button>
</form>
{isInvalid && (
<p className="mt-2 text-sm text-destructive">
Gecersiz VIN. 17 karakter olmali, I, O, Q harfleri kullanilamaz.
</p>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
);
}

View File

@@ -1,4 +1,4 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
const API_URL = "/api";
type RequestOptions = {
method?: string;

View File

@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || "http://localhost:4000",
baseURL: typeof window !== "undefined" ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"),
basePath: "/api/auth",
});

View File

@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
const publicPaths = ["/", "/pricing", "/login", "/register", "/forgot-password", "/reset-password"];
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,13 +15,27 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}