From b42c5154e31826f82e84c445de14e45cb49285da Mon Sep 17 00:00:00 2001 From: quangdn-ght Date: Mon, 7 Jul 2025 21:37:16 +0700 Subject: [PATCH] cap nhat auth he thong de nhung voi chebichat --- AUTH_SYSTEM.md | 259 ++++++++++++++++++++ app/api/auth/callback/route.ts | 174 ++++++++++++++ app/api/auth/check/route.ts | 59 ++++- app/api/auth/logout/route.ts | 68 ++++++ app/api/auth/refresh/route.ts | 99 ++++++++ app/api/supabase.ts | 154 +++++++++++- app/api/user/route.ts | 102 ++++++++ app/auth-demo/page.tsx | 220 +++++++++++++++++ app/components/chat.tsx | 2 +- app/components/storage-initializer.tsx | 29 +++ app/constant.ts | 1 + app/hooks/useAuth.ts | 227 ++++++++++++++++++ app/hooks/useAuth.tsx | 217 +++++++++++++++++ app/layout.tsx | 4 + app/login/page.tsx | 150 ++++++++++++ app/profile/page.tsx | 74 ++++++ app/utils/indexedDB-storage.ts | 192 ++++++++++++++- app/utils/storage-debug.ts | 236 ++++++++++++++++++ app/utils/storage-migration.ts | 319 +++++++++++++++++++++++++ app/utils/store.ts | 30 ++- git.sh | 2 +- middleware.ts | 104 ++++++++ run.sh | 2 +- test_auth.sh | 98 ++++++++ test_indexeddb.sh | 176 ++++++++++++++ 25 files changed, 2970 insertions(+), 28 deletions(-) create mode 100644 AUTH_SYSTEM.md create mode 100644 app/api/auth/callback/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/refresh/route.ts create mode 100644 app/api/user/route.ts create mode 100644 app/auth-demo/page.tsx create mode 100644 app/components/storage-initializer.tsx create mode 100644 app/hooks/useAuth.ts create mode 100644 app/hooks/useAuth.tsx create mode 100644 app/login/page.tsx create mode 100644 app/profile/page.tsx create mode 100644 app/utils/storage-debug.ts create mode 100644 app/utils/storage-migration.ts create mode 100644 middleware.ts create mode 100755 test_auth.sh create mode 100644 test_indexeddb.sh diff --git a/AUTH_SYSTEM.md b/AUTH_SYSTEM.md new file mode 100644 index 000000000..6d4b4b373 --- /dev/null +++ b/AUTH_SYSTEM.md @@ -0,0 +1,259 @@ +# Authentication System Documentation + +This authentication system provides a complete solution for handling Supabase authentication tokens via cookies and query strings. + +## Features + +- **Token-based Authentication**: Accepts authentication tokens via query strings or POST requests +- **Automatic Cookie Management**: Stores tokens securely as HTTP-only cookies +- **Token Refresh**: Automatically refreshes expired tokens using refresh tokens +- **Route Protection**: Middleware to protect routes requiring authentication +- **Client-side Hooks**: React hooks for managing authentication state +- **Session Management**: Proper login/logout functionality + +## API Endpoints + +### Authentication Callback +Handle authentication tokens from external services or redirects. + +#### GET `/api/auth/callback` +``` +GET /api/auth/callback?token=ACCESS_TOKEN&refresh_token=REFRESH_TOKEN&redirect_to=/dashboard +``` + +Parameters: +- `token` or `access_token` (required): The Supabase access token +- `refresh_token` (optional): The refresh token for automatic token renewal +- `redirect_to` (optional): URL to redirect to after successful authentication (default: "/") + +#### POST `/api/auth/callback` +```json +POST /api/auth/callback +Content-Type: application/json + +{ + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "redirect_to": "/dashboard" +} +``` + +### Authentication Check +Verify current authentication status. + +#### GET `/api/auth/check` +``` +GET /api/auth/check +``` + +Returns: +```json +{ + "authenticated": true, + "user": { + "id": "user_id", + "email": "user@example.com", + "user_metadata": {} + }, + "userInfo": { + "id": "user_id", + "email": "user@example.com", + "user_metadata": {} + } +} +``` + +### Token Refresh +Manually refresh an expired access token. + +#### POST `/api/auth/refresh` +``` +POST /api/auth/refresh +``` + +Uses the refresh token stored in cookies to get a new access token. + +### Logout +Clear authentication cookies and end the session. + +#### POST `/api/auth/logout` +```json +POST /api/auth/logout +``` + +#### GET `/api/auth/logout` +``` +GET /api/auth/logout?redirect_to=/login +``` + +### User Information +Get detailed user information (protected route). + +#### GET `/api/user` +``` +GET /api/user +``` + +Returns detailed user information including metadata. + +## Client-side Usage + +### Using the useAuth Hook + +```tsx +import { useAuth } from "../hooks/useAuth"; + +function MyComponent() { + const { user, authenticated, loading, login, logout, checkAuth } = useAuth(); + + if (loading) { + return
Loading...
; + } + + if (!authenticated) { + return ( +
+ +
+ ); + } + + return ( +
+

Welcome, {user?.email}!

+ +
+ ); +} +``` + +### Protecting Routes with HOC + +```tsx +import { withAuth } from "../hooks/useAuth"; + +function ProtectedPage({ user }) { + return ( +
+

Protected Content

+

User ID: {user.id}

+
+ ); +} + +export default withAuth(ProtectedPage); +``` + +## Server-side Route Protection + +The middleware automatically protects routes based on the configuration in `middleware.ts`. + +### Protected Routes (require authentication): +- `/chat` +- `/settings` +- `/profile` +- `/api/chat` +- `/api/user` + +### Public Routes (no authentication required): +- `/` +- `/login` +- `/signup` +- `/api/auth/*` + +## Environment Variables + +Ensure these environment variables are set: + +```env +SUPABASE_URL=your_supabase_project_url +SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +## Authentication Flow Examples + +### 1. External Service Redirect +``` +https://your-app.com/api/auth/callback?token=supabase_access_token&refresh_token=supabase_refresh_token&redirect_to=/dashboard +``` + +### 2. Programmatic Login +```javascript +// Client-side login +const { login } = useAuth(); +await login("supabase_access_token", "supabase_refresh_token"); +``` + +### 3. Form-based Login +```javascript +// Using the login page +// Navigate to /login and enter token manually +``` + +## Cookie Configuration + +The system uses secure HTTP-only cookies with the following settings: + +- **Name**: `sb-access-token`, `sb-refresh-token`, `sb-user-info` +- **HttpOnly**: `true` (except for `sb-user-info`) +- **Secure**: `true` (in production) +- **SameSite**: `lax` +- **MaxAge**: 7 days +- **Path**: `/` + +## Error Handling + +The system handles various error scenarios: + +- **No token provided**: Redirects to login with error parameter +- **Invalid token**: Validates token with Supabase before setting cookies +- **Expired token**: Automatically attempts refresh using refresh token +- **Network errors**: Graceful fallback and error messaging + +## Integration with Existing Code + +The authentication system is designed to work alongside existing authentication mechanisms. It enhances the current system by providing: + +1. **Cookie-based session management** +2. **Automatic token refresh** +3. **Client-side authentication state** +4. **Route protection middleware** + +## Testing the Implementation + +1. **Start the application**: + ```bash + npm run dev + ``` + +2. **Test authentication callback**: + ``` + http://localhost:3000/api/auth/callback?token=YOUR_SUPABASE_TOKEN + ``` + +3. **Visit protected routes**: + ``` + http://localhost:3000/profile + ``` + +4. **Test the login page**: + ``` + http://localhost:3000/login + ``` + +5. **Check authentication status**: + ``` + curl -b cookies.txt http://localhost:3000/api/auth/check + ``` + +## Security Considerations + +1. **HTTP-only cookies**: Prevents XSS attacks +2. **Secure flag**: Ensures cookies are only sent over HTTPS in production +3. **Token validation**: All tokens are validated with Supabase before use +4. **Automatic expiration**: Cookies expire after 7 days +5. **Path restriction**: Cookies are scoped to the application path + +This authentication system provides a robust foundation for managing user sessions while maintaining security best practices. diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 000000000..208837bf1 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -0,0 +1,174 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; + +const SUPABASE_URL = process.env.SUPABASE_URL!; +const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const authToken = url.searchParams.get("token"); + const access_token = url.searchParams.get("access_token"); + const refresh_token = url.searchParams.get("refresh_token"); + const redirectTo = url.searchParams.get("redirect_to") || "/"; + + console.log("[Auth Callback] Processing authentication callback"); + console.log("[Auth Callback] authToken:", authToken); + console.log("[Auth Callback] access_token:", access_token); + console.log("[Auth Callback] refresh_token:", refresh_token); + + // Use either authToken or access_token (flexible for different auth flows) + const token = authToken || access_token; + + if (!token) { + console.log( + "[Auth Callback] No authentication token found in query string", + ); + return NextResponse.redirect(new URL("/login?error=no_token", req.url)); + } + + try { + // Validate the token with Supabase + const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }); + + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) { + console.error("[Auth Callback] Invalid token:", error); + return NextResponse.redirect( + new URL("/login?error=invalid_token", req.url), + ); + } + + console.log( + "[Auth Callback] Token validated successfully for user:", + data.user.id, + ); + + // Create response with redirect + const response = NextResponse.redirect(new URL(redirectTo, req.url)); + + // Set authentication cookies + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 60 * 60 * 24 * 7, // 7 days + path: "/", + }; + + // Set access token cookie + response.cookies.set("sb-access-token", token, cookieOptions); + + // Set refresh token if available + if (refresh_token) { + response.cookies.set("sb-refresh-token", refresh_token, cookieOptions); + } + + // Set user info cookie (optional, for quick access) + response.cookies.set( + "sb-user-info", + JSON.stringify({ + id: data.user.id, + email: data.user.email, + user_metadata: data.user.user_metadata, + }), + { + ...cookieOptions, + httpOnly: false, // Allow client-side access for user info + }, + ); + + console.log("[Auth Callback] Authentication cookies set successfully"); + + return response; + } catch (err) { + console.error("[Auth Callback] Error processing authentication:", err); + return NextResponse.redirect(new URL("/login?error=auth_failed", req.url)); + } +} + +export async function POST(req: NextRequest) { + // Handle POST requests for programmatic token setting + try { + const body = await req.json(); + const { access_token, refresh_token, redirect_to = "/" } = body; + + if (!access_token) { + return NextResponse.json( + { error: "access_token is required" }, + { status: 400 }, + ); + } + + // Validate the token + const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }, + }); + + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) { + return NextResponse.json( + { error: "Invalid token", details: error }, + { status: 401 }, + ); + } + + // Create response + const response = NextResponse.json({ + success: true, + user: { + id: data.user.id, + email: data.user.email, + user_metadata: data.user.user_metadata, + }, + redirect_to, + }); + + // Set authentication cookies + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 60 * 60 * 24 * 7, // 7 days + path: "/", + }; + + response.cookies.set("sb-access-token", access_token, cookieOptions); + + if (refresh_token) { + response.cookies.set("sb-refresh-token", refresh_token, cookieOptions); + } + + response.cookies.set( + "sb-user-info", + JSON.stringify({ + id: data.user.id, + email: data.user.email, + user_metadata: data.user.user_metadata, + }), + { + ...cookieOptions, + httpOnly: false, + }, + ); + + return response; + } catch (err) { + console.error("[Auth Callback POST] Error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/check/route.ts b/app/api/auth/check/route.ts index 63fbfa9bc..bc8cfeec3 100644 --- a/app/api/auth/check/route.ts +++ b/app/api/auth/check/route.ts @@ -1,21 +1,54 @@ // /app/api/auth/check/route.ts -import { NextRequest } from "next/server"; -import { checkAuth } from "../../supabase"; +import { NextRequest, NextResponse } from "next/server"; +import { checkAuthWithRefresh, getUserInfoFromCookie } from "../../supabase"; export async function GET(req: NextRequest) { - const user = await checkAuth(req); + try { + const authResult = await checkAuthWithRefresh(req); + const userInfo = getUserInfoFromCookie(req); - console.log("[Auth] user ", user); + console.log("[Auth Check] user:", authResult.user?.email || "none"); - if (!user) { - return new Response(JSON.stringify({ authenticated: false }), { - status: 401, - headers: { "Content-Type": "application/json" }, + if (!authResult.user) { + return NextResponse.json( + { + authenticated: false, + error: "No valid authentication found", + }, + { status: 401 }, + ); + } + + // Create response + const response = NextResponse.json({ + authenticated: true, + user: authResult.user, + userInfo: userInfo, // Include cached user info for quick access }); - } - return new Response(JSON.stringify({ authenticated: true, user }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + // If token was refreshed, merge the refreshed cookies + if (authResult.needsRefresh && authResult.response) { + // Copy cookies from refresh response + authResult.response.cookies.getAll().forEach((cookie) => { + response.cookies.set(cookie.name, cookie.value, { + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + maxAge: cookie.maxAge, + path: cookie.path, + }); + }); + } + + return response; + } catch (error) { + console.error("[Auth Check] Error:", error); + return NextResponse.json( + { + authenticated: false, + error: "Authentication check failed", + }, + { status: 500 }, + ); + } } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 000000000..d15ee26a3 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + console.log("[Auth Logout] Processing logout request"); + + const redirectTo = + new URL(req.url).searchParams.get("redirect_to") || "/login"; + + // Create response + const response = NextResponse.json({ + success: true, + message: "Logged out successfully", + }); + + // Clear authentication cookies + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 0, // Expire immediately + path: "/", + }; + + response.cookies.set("sb-access-token", "", cookieOptions); + response.cookies.set("sb-refresh-token", "", cookieOptions); + response.cookies.set("sb-user-info", "", { + ...cookieOptions, + httpOnly: false, + }); + + console.log("[Auth Logout] Authentication cookies cleared"); + + return response; +} + +export async function GET(req: NextRequest) { + // Handle GET requests with redirect + const url = new URL(req.url); + const redirectTo = url.searchParams.get("redirect_to") || "/login"; + + console.log("[Auth Logout] Processing logout request with redirect"); + + // Create redirect response + const response = NextResponse.redirect(new URL(redirectTo, req.url)); + + // Clear authentication cookies + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 0, // Expire immediately + path: "/", + }; + + response.cookies.set("sb-access-token", "", cookieOptions); + response.cookies.set("sb-refresh-token", "", cookieOptions); + response.cookies.set("sb-user-info", "", { + ...cookieOptions, + httpOnly: false, + }); + + console.log( + "[Auth Logout] Authentication cookies cleared, redirecting to:", + redirectTo, + ); + + return response; +} diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 000000000..bee106cad --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; + +const SUPABASE_URL = process.env.SUPABASE_URL!; +const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!; + +export async function POST(req: NextRequest) { + console.log("[Auth Refresh] Processing token refresh request"); + + const refreshToken = req.cookies.get("sb-refresh-token")?.value; + + if (!refreshToken) { + console.log("[Auth Refresh] No refresh token found"); + return NextResponse.json( + { error: "No refresh token found" }, + { status: 401 }, + ); + } + + try { + const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); + + const { data, error } = await supabase.auth.refreshSession({ + refresh_token: refreshToken, + }); + + if (error || !data?.session) { + console.error("[Auth Refresh] Token refresh failed:", error); + return NextResponse.json( + { error: "Token refresh failed", details: error }, + { status: 401 }, + ); + } + + console.log( + "[Auth Refresh] Token refreshed successfully for user:", + data.session.user.id, + ); + + // Create response + const response = NextResponse.json({ + success: true, + user: { + id: data.session.user.id, + email: data.session.user.email, + user_metadata: data.session.user.user_metadata, + }, + session: { + access_token: data.session.access_token, + expires_at: data.session.expires_at, + }, + }); + + // Update authentication cookies + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 60 * 60 * 24 * 7, // 7 days + path: "/", + }; + + response.cookies.set( + "sb-access-token", + data.session.access_token, + cookieOptions, + ); + + if (data.session.refresh_token) { + response.cookies.set( + "sb-refresh-token", + data.session.refresh_token, + cookieOptions, + ); + } + + // Update user info cookie + response.cookies.set( + "sb-user-info", + JSON.stringify({ + id: data.session.user.id, + email: data.session.user.email, + user_metadata: data.session.user.user_metadata, + }), + { + ...cookieOptions, + httpOnly: false, + }, + ); + + return response; + } catch (err) { + console.error("[Auth Refresh] Error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/supabase.ts b/app/api/supabase.ts index 5dbc05885..170921d39 100644 --- a/app/api/supabase.ts +++ b/app/api/supabase.ts @@ -1,5 +1,5 @@ import { createClient } from "@supabase/supabase-js"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; const SUPABASE_URL = process.env.SUPABASE_URL!; const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY!; @@ -42,3 +42,155 @@ export async function checkAuth(req: NextRequest) { return null; } } + +// Enhanced auth check with token refresh capability +export async function checkAuthWithRefresh(req: NextRequest): Promise<{ + user: any | null; + response?: NextResponse; + needsRefresh?: boolean; +}> { + const authToken = req.cookies.get("sb-access-token")?.value; + const refreshToken = req.cookies.get("sb-refresh-token")?.value; + + if (!authToken) { + console.log("[Supabase] No auth token found"); + return { user: null }; + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + }); + + try { + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) { + // Token might be expired, try to refresh if refresh token is available + if (refreshToken && error?.message?.includes("JWT expired")) { + console.log("[Supabase] Access token expired, attempting refresh"); + + try { + const { data: refreshData, error: refreshError } = + await supabase.auth.refreshSession({ + refresh_token: refreshToken, + }); + + if (refreshError || !refreshData?.session) { + console.error("[Supabase] Token refresh failed:", refreshError); + return { user: null }; + } + + console.log("[Supabase] Token refreshed successfully"); + + // Create response with updated cookies + const response = new NextResponse(); + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 60 * 60 * 24 * 7, // 7 days + path: "/", + }; + + response.cookies.set( + "sb-access-token", + refreshData.session.access_token, + cookieOptions, + ); + + if (refreshData.session.refresh_token) { + response.cookies.set( + "sb-refresh-token", + refreshData.session.refresh_token, + cookieOptions, + ); + } + + // Update user info cookie + response.cookies.set( + "sb-user-info", + JSON.stringify({ + id: refreshData.session.user.id, + email: refreshData.session.user.email, + user_metadata: refreshData.session.user.user_metadata, + }), + { + ...cookieOptions, + httpOnly: false, + }, + ); + + return { + user: refreshData.session.user, + response, + needsRefresh: true, + }; + } catch (refreshErr) { + console.error("[Supabase] Error during token refresh:", refreshErr); + return { user: null }; + } + } + + console.error("[Supabase] Error getting user:", error); + return { user: null }; + } + + console.log("[Supabase] Authenticated user:", data.user); + return { user: data.user }; + } catch (err) { + console.error("[Supabase] Error fetching user data:", err); + return { user: null }; + } +} + +// Utility function to get user info from cookie (client-side accessible) +export function getUserInfoFromCookie(req: NextRequest) { + const userInfoCookie = req.cookies.get("sb-user-info")?.value; + + if (!userInfoCookie) { + return null; + } + + try { + return JSON.parse(userInfoCookie); + } catch (err) { + console.error("[Supabase] Error parsing user info cookie:", err); + return null; + } +} + +// Middleware helper to protect routes +export function createAuthMiddleware(protectedPaths: string[] = []) { + return async function authMiddleware(req: NextRequest) { + const { pathname } = req.nextUrl; + + // Check if this path requires authentication + const isProtectedPath = protectedPaths.some( + (path) => pathname.startsWith(path) || pathname === path, + ); + + if (!isProtectedPath) { + return NextResponse.next(); + } + + const authResult = await checkAuthWithRefresh(req); + + if (!authResult.user) { + // Redirect to login if not authenticated + const loginUrl = new URL("/login", req.url); + loginUrl.searchParams.set("redirect_to", pathname); + return NextResponse.redirect(loginUrl); + } + + // If token was refreshed, return the response with updated cookies + if (authResult.needsRefresh && authResult.response) { + return authResult.response; + } + + return NextResponse.next(); + }; +} diff --git a/app/api/user/route.ts b/app/api/user/route.ts new file mode 100644 index 000000000..37a201307 --- /dev/null +++ b/app/api/user/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkAuthWithRefresh } from "../supabase"; + +export async function GET(req: NextRequest) { + console.log("[User API] Processing user info request"); + + try { + const authResult = await checkAuthWithRefresh(req); + + if (!authResult.user) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 }, + ); + } + + console.log("[User API] Returning user info for:", authResult.user.email); + + // Create response + const response = NextResponse.json({ + user: { + id: authResult.user.id, + email: authResult.user.email, + user_metadata: authResult.user.user_metadata, + created_at: authResult.user.created_at, + updated_at: authResult.user.updated_at, + last_sign_in_at: authResult.user.last_sign_in_at, + }, + }); + + // If token was refreshed, merge the refreshed cookies + if (authResult.needsRefresh && authResult.response) { + authResult.response.cookies.getAll().forEach((cookie: any) => { + response.cookies.set(cookie.name, cookie.value, { + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + maxAge: cookie.maxAge, + path: cookie.path, + }); + }); + } + + return response; + } catch (error) { + console.error("[User API] Error:", error); + return NextResponse.json( + { error: "Failed to fetch user information" }, + { status: 500 }, + ); + } +} + +export async function PUT(req: NextRequest) { + console.log("[User API] Processing user update request"); + + try { + const authResult = await checkAuthWithRefresh(req); + + if (!authResult.user) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 }, + ); + } + + const body = await req.json(); + const { user_metadata } = body; + + // Note: In a real application, you would update the user in Supabase here + // For now, we'll just return the current user data + console.log("[User API] User update requested for:", authResult.user.email); + console.log("[User API] Update data:", user_metadata); + + // Create response + const response = NextResponse.json({ + user: authResult.user, + message: "User update functionality would be implemented here", + }); + + // If token was refreshed, merge the refreshed cookies + if (authResult.needsRefresh && authResult.response) { + authResult.response.cookies.getAll().forEach((cookie: any) => { + response.cookies.set(cookie.name, cookie.value, { + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + maxAge: cookie.maxAge, + path: cookie.path, + }); + }); + } + + return response; + } catch (error) { + console.error("[User API] Error:", error); + return NextResponse.json( + { error: "Failed to update user information" }, + { status: 500 }, + ); + } +} diff --git a/app/auth-demo/page.tsx b/app/auth-demo/page.tsx new file mode 100644 index 000000000..f575f3bf1 --- /dev/null +++ b/app/auth-demo/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import React from "react"; +import { useAuth } from "../hooks/useAuth"; + +export default function AuthDemoPage() { + const { user, authenticated, loading, logout } = useAuth(); + + if (loading) { + return ( +
+
+
+

Loading authentication status...

+
+
+ ); + } + + return ( +
+
+
+

+ 🔐 Authentication System Demo +

+
+ +
+ {/* Authentication Status Card */} +
+

+ Authentication Status +

+ +
+
+ + Status: + + + {authenticated ? "✅ Authenticated" : "❌ Not Authenticated"} + +
+ + {user?.id && ( +
+ + User ID: + + + {user.id} + +
+ )} + + {user?.email && ( +
+ + Email: + + {user.email} +
+ )} +
+ + {authenticated && ( +
+ +
+ )} +
+ + {/* Quick Actions Card */} +
+

+ Quick Actions +

+ + +
+ + {/* Authentication Flow Card */} +
+

+ 🔄 Authentication Flow +

+ +
+
+

+ 1. Token Authentication via URL +

+ + GET + /api/auth/callback?token=YOUR_SUPABASE_TOKEN&refresh_token=REFRESH_TOKEN&redirect_to=/profile + +
+ +
+

+ 2. Programmatic Login via POST +

+ + POST /api/auth/callback +
+ {`{"access_token": "token", "refresh_token": "refresh"}`} +
+
+ +
+

+ 3. Manual Token Entry +

+

+ Visit the{" "} + + login page + {" "} + to enter your Supabase tokens manually. +

+
+
+
+ + {/* API Endpoints Card */} +
+

+ 🚀 Available API Endpoints +

+ +
+
+

+ Authentication +

+
    +
  • + GET /api/auth/check - Check auth status +
  • +
  • + POST /api/auth/callback - Login with tokens +
  • +
  • + POST /api/auth/refresh - Refresh access token +
  • +
  • + POST /api/auth/logout - Logout and clear + cookies +
  • +
+
+ +
+

+ Protected Routes +

+
    +
  • + GET /api/user - Get user information +
  • +
  • + GET /profile - User profile page +
  • +
  • + GET /chat - Chat interface (if implemented) +
  • +
  • + GET /settings - Settings page (if implemented) +
  • +
+
+
+
+
+ +
+

+ 📚 See AUTH_SYSTEM.md for detailed documentation +

+
+
+
+ ); +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 4712b03f5..e1bf0ead4 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1458,7 +1458,7 @@ function _Chat() { const autoFocus = isIOS() ? false : !isMobileScreen; // wont auto focus on mobile screen - console.log("tu dong focus:", autoFocus); + // console.log("tu dong focus:", autoFocus); const showMaxIcon = !isMobileScreen && !clientConfig?.isApp; diff --git a/app/components/storage-initializer.tsx b/app/components/storage-initializer.tsx new file mode 100644 index 000000000..b65f34f05 --- /dev/null +++ b/app/components/storage-initializer.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; + +export default function StorageInitializer() { + useEffect(() => { + // Initialize storage migration and debugging in development + if (process.env.NODE_ENV === "development") { + // Import and initialize storage utilities + Promise.all([ + import("../utils/storage-debug"), + import("../utils/storage-migration"), + ]) + .then(([storageDebug, storageMigration]) => { + console.log("🔧 Storage utilities loaded"); + + // Run a quick health check + if ((window as any).debugStorage?.checkStorageHealth) { + (window as any).debugStorage.checkStorageHealth(); + } + }) + .catch((error) => { + console.error("Failed to load storage utilities:", error); + }); + } + }, []); + + return null; // This component doesn't render anything +} diff --git a/app/constant.ts b/app/constant.ts index 53bd011bc..c746124c5 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -102,6 +102,7 @@ export enum StoreKey { Sync = "sync", SdList = "sd-list", Mcp = "mcp-store", + Auth = "auth-store", } export const DEFAULT_SIDEBAR_WIDTH = 300; diff --git a/app/hooks/useAuth.ts b/app/hooks/useAuth.ts new file mode 100644 index 000000000..17a6240ce --- /dev/null +++ b/app/hooks/useAuth.ts @@ -0,0 +1,227 @@ +"use client"; + +import React, { useState, useEffect } from "react"; + +export interface User { + id: string; + email?: string; + user_metadata?: any; +} + +export interface AuthState { + user: User | null; + loading: boolean; + authenticated: boolean; +} + +export function useAuth() { + const [authState, setAuthState] = useState({ + user: null, + loading: true, + authenticated: false, + }); + + const checkAuth = async () => { + try { + const response = await fetch("/api/auth/check", { + method: "GET", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + if (data.authenticated) { + setAuthState({ + user: data.user, + loading: false, + authenticated: true, + }); + return data.user; + } + } + + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } catch (error) { + console.error("Auth check failed:", error); + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } + }; + + const login = async (accessToken: string, refreshToken?: string) => { + try { + const response = await fetch("/api/auth/callback", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken, + }), + }); + + if (response.ok) { + const data = await response.json(); + setAuthState({ + user: data.user, + loading: false, + authenticated: true, + }); + return data.user; + } else { + throw new Error("Login failed"); + } + } catch (error) { + console.error("Login failed:", error); + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + throw error; + } + }; + + const logout = async () => { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("Logout request failed:", error); + } finally { + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + // Redirect to login page + window.location.href = "/login"; + } + }; + + const refreshToken = async () => { + try { + const response = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + setAuthState({ + user: data.user, + loading: false, + authenticated: true, + }); + return data.user; + } else { + // Refresh failed, user needs to login again + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } + } catch (error) { + console.error("Token refresh failed:", error); + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } + }; + + const getUserFromCookie = () => { + if (typeof window === "undefined") return null; + + try { + const userInfoCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("sb-user-info=")); + + if (userInfoCookie) { + const userInfo = decodeURIComponent(userInfoCookie.split("=")[1]); + return JSON.parse(userInfo); + } + } catch (error) { + console.error("Error reading user cookie:", error); + } + + return null; + }; + + useEffect(() => { + // Try to get user from cookie first for faster initial load + const cachedUser = getUserFromCookie(); + if (cachedUser) { + setAuthState({ + user: cachedUser, + loading: true, // Still check with server + authenticated: true, + }); + } + + // Then verify with server + checkAuth(); + }, []); + + return { + ...authState, + checkAuth, + login, + logout, + refreshToken, + getUserFromCookie, + }; +} + +// Higher-order component for protecting routes +export function withAuth>( + Component: React.ComponentType, +) { + const AuthenticatedComponent = (props: T) => { + const { authenticated, loading } = useAuth(); + + useEffect(() => { + if (!loading && !authenticated) { + // Redirect to login if not authenticated + const currentPath = window.location.pathname; + window.location.href = `/login?redirect_to=${encodeURIComponent( + currentPath, + )}`; + } + }, [authenticated, loading]); + + if (loading) { + return React.createElement("div", null, "Loading..."); + } + + if (!authenticated) { + return React.createElement("div", null, "Redirecting to login..."); + } + + return React.createElement(Component, props); + }; + + AuthenticatedComponent.displayName = `withAuth(${ + Component.displayName || Component.name + })`; + + return AuthenticatedComponent; +} diff --git a/app/hooks/useAuth.tsx b/app/hooks/useAuth.tsx new file mode 100644 index 000000000..eb4023462 --- /dev/null +++ b/app/hooks/useAuth.tsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from "react"; + +export interface User { + id: string; + email?: string; + user_metadata?: any; +} + +export interface AuthState { + user: User | null; + loading: boolean; + authenticated: boolean; +} + +export function useAuth() { + const [authState, setAuthState] = useState({ + user: null, + loading: true, + authenticated: false, + }); + + const checkAuth = async () => { + try { + const response = await fetch("/api/auth/check", { + method: "GET", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + if (data.authenticated) { + setAuthState({ + user: data.user, + loading: false, + authenticated: true, + }); + return data.user; + } + } + + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } catch (error) { + console.error("Auth check failed:", error); + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } + }; + + const login = async (accessToken: string, refreshToken?: string) => { + try { + const response = await fetch("/api/auth/callback", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken, + }), + }); + + if (response.ok) { + const data = await response.json(); + setAuthState({ + user: data.user, + loading: false, + authenticated: true, + }); + return data.user; + } else { + throw new Error("Login failed"); + } + } catch (error) { + console.error("Login failed:", error); + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + throw error; + } + }; + + const logout = async () => { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("Logout request failed:", error); + } finally { + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + // Redirect to login page + window.location.href = "/login"; + } + }; + + const refreshToken = async () => { + try { + const response = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + setAuthState({ + user: data.user, + loading: false, + authenticated: true, + }); + return data.user; + } else { + // Refresh failed, user needs to login again + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } + } catch (error) { + console.error("Token refresh failed:", error); + setAuthState({ + user: null, + loading: false, + authenticated: false, + }); + return null; + } + }; + + const getUserFromCookie = () => { + if (typeof window === "undefined") return null; + + try { + const userInfoCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("sb-user-info=")); + + if (userInfoCookie) { + const userInfo = decodeURIComponent(userInfoCookie.split("=")[1]); + return JSON.parse(userInfo); + } + } catch (error) { + console.error("Error reading user cookie:", error); + } + + return null; + }; + + useEffect(() => { + // Try to get user from cookie first for faster initial load + const cachedUser = getUserFromCookie(); + if (cachedUser) { + setAuthState({ + user: cachedUser, + loading: true, // Still check with server + authenticated: true, + }); + } + + // Then verify with server + checkAuth(); + }, []); + + return { + ...authState, + checkAuth, + login, + logout, + refreshToken, + getUserFromCookie, + }; +} + +// Higher-order component for protecting routes +export function withAuth(Component: React.ComponentType) { + return function AuthenticatedComponent(props: T) { + const { authenticated, loading, user } = useAuth(); + + useEffect(() => { + if (!loading && !authenticated) { + // Redirect to login if not authenticated + const currentPath = window.location.pathname; + window.location.href = `/login?redirect_to=${encodeURIComponent( + currentPath, + )}`; + } + }, [authenticated, loading]); + + if (loading) { + return
Loading...
; + } + + if (!authenticated) { + return
Redirecting to login...
; + } + + return ; + }; +} diff --git a/app/layout.tsx b/app/layout.tsx index 000b497e8..f6a2e05c9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next"; import { GoogleTagManager, GoogleAnalytics } from "@next/third-parties/google"; import { getServerSideConfig } from "./config/server"; import SyncOnFirstLoad from "./SyncOnFirstLoad"; +import StorageInitializer from "./components/storage-initializer"; const TITLE = "Chebi Chat - Trợ lý AI học tiếng Trung"; export const metadata: Metadata = { @@ -58,6 +59,9 @@ export default function RootLayout({ {children} + {/* Storage System Initialization */} + + {serverConfig?.isVercel && ( <> diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 000000000..8f45aef9a --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "../hooks/useAuth"; + +export default function LoginPage() { + const [token, setToken] = useState(""); + const [refreshToken, setRefreshToken] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const { login, authenticated } = useAuth(); + + const redirectTo = searchParams.get("redirect_to") || "/"; + const errorParam = searchParams.get("error"); + + useEffect(() => { + if (authenticated) { + router.push(redirectTo); + } + }, [authenticated, redirectTo, router]); + + useEffect(() => { + if (errorParam) { + switch (errorParam) { + case "no_token": + setError("No authentication token provided"); + break; + case "invalid_token": + setError("Invalid authentication token"); + break; + case "auth_failed": + setError("Authentication failed"); + break; + case "auth_check_failed": + setError("Authentication check failed"); + break; + default: + setError("An error occurred during authentication"); + } + } + }, [errorParam]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!token.trim()) { + setError("Please enter an access token"); + return; + } + + setLoading(true); + setError(""); + + try { + await login(token.trim(), refreshToken.trim() || undefined); + router.push(redirectTo); + } catch (err) { + setError("Login failed. Please check your token and try again."); + } finally { + setLoading(false); + } + }; + + if (authenticated) { + return
Redirecting...
; + } + + return ( +
+
+
+

+ Sign in to your account +

+

+ Enter your Supabase authentication token +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setToken(e.target.value)} + /> +
+ +
+ + setRefreshToken(e.target.value)} + /> +
+ +
+ +
+
+ +
+
+

+ You can also authenticate by visiting:{" "} + + /api/auth/callback?token=YOUR_TOKEN + +

+
+
+
+
+ ); +} diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 000000000..6c8beb40a --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import React from "react"; +import { useAuth, withAuth } from "../hooks/useAuth"; + +function ProfilePage() { + const { user, logout } = useAuth(); + + const handleLogout = () => { + logout(); + }; + + return ( +
+
+
+
+

+ Profile Information +

+ +
+
+ +

{user?.id}

+
+ + {user?.email && ( +
+ +

{user.email}

+
+ )} + + {user?.user_metadata && + Object.keys(user.user_metadata).length > 0 && ( +
+ +
+                      {JSON.stringify(user.user_metadata, null, 2)}
+                    
+
+ )} +
+ +
+ + + +
+
+
+
+
+ ); +} + +export default withAuth(ProfilePage); diff --git a/app/utils/indexedDB-storage.ts b/app/utils/indexedDB-storage.ts index 51417e9f3..b24a27ca2 100644 --- a/app/utils/indexedDB-storage.ts +++ b/app/utils/indexedDB-storage.ts @@ -5,41 +5,213 @@ import { safeLocalStorage } from "@/app/utils"; const localStorage = safeLocalStorage(); class IndexedDBStorage implements StateStorage { + private isIndexedDBAvailable = true; + public async getItem(name: string): Promise { try { - const value = (await get(name)) || localStorage.getItem(name); - return value; + // First try IndexedDB + if (this.isIndexedDBAvailable) { + const value = await get(name); + if (value !== undefined) { + return value; + } + } + + // Fallback to localStorage + const localValue = localStorage.getItem(name); + console.log(`[IndexedDB] Retrieved from localStorage for key: ${name}`); + return localValue; } catch (error) { + console.error(`[IndexedDB] Error getting item ${name}:`, error); + this.isIndexedDBAvailable = false; return localStorage.getItem(name); } } public async setItem(name: string, value: string): Promise { try { - const _value = JSON.parse(value); - if (!_value?.state?._hasHydrated) { - console.warn("skip setItem", name); + // Validate JSON structure + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch (parseError) { + console.error(`[IndexedDB] Invalid JSON for key ${name}:`, parseError); + // Still try to store the raw value + parsedValue = null; + } + + // Check if this is a Zustand store with hydration state + const isZustandStore = + parsedValue && + typeof parsedValue === "object" && + parsedValue.state && + typeof parsedValue.state === "object"; + + // For Zustand stores, check hydration status + if (isZustandStore) { + const hasHydrated = parsedValue.state._hasHydrated; + + // Allow storage if: + // 1. Already hydrated, OR + // 2. Initial state (not hydrated but has meaningful data) + const shouldStore = + hasHydrated || + (parsedValue.state.sessions && + parsedValue.state.sessions.length > 0) || + Object.keys(parsedValue.state).length > 2; // More than just _hasHydrated and version + + if (!shouldStore) { + console.log( + `[IndexedDB] Skipping storage for ${name} - not hydrated and no meaningful data`, + ); + return; + } + } + + // Try IndexedDB first + if (this.isIndexedDBAvailable) { + await set(name, value); + console.log(`[IndexedDB] Successfully stored ${name} in IndexedDB`); + + // Also store in localStorage as backup for critical stores + if ( + name.includes("chat") || + name.includes("config") || + name.includes("access") + ) { + localStorage.setItem(name, value); + } return; } - await set(name, value); - } catch (error) { + + // Fallback to localStorage localStorage.setItem(name, value); + console.log( + `[IndexedDB] Stored ${name} in localStorage (IndexedDB unavailable)`, + ); + } catch (error) { + console.error(`[IndexedDB] Error setting item ${name}:`, error); + this.isIndexedDBAvailable = false; + + // Always fallback to localStorage on error + try { + localStorage.setItem(name, value); + console.log(`[IndexedDB] Fallback: stored ${name} in localStorage`); + } catch (localError) { + console.error( + `[IndexedDB] Failed to store ${name} in localStorage:`, + localError, + ); + } } } public async removeItem(name: string): Promise { try { - await del(name); + // Remove from both storages to ensure cleanup + if (this.isIndexedDBAvailable) { + await del(name); + } + localStorage.removeItem(name); + console.log(`[IndexedDB] Removed ${name} from both storages`); } catch (error) { + console.error(`[IndexedDB] Error removing item ${name}:`, error); + this.isIndexedDBAvailable = false; localStorage.removeItem(name); } } public async clear(): Promise { try { - await clear(); - } catch (error) { + // Clear both storages + if (this.isIndexedDBAvailable) { + await clear(); + } localStorage.clear(); + console.log(`[IndexedDB] Cleared both storages`); + } catch (error) { + console.error(`[IndexedDB] Error clearing storage:`, error); + this.isIndexedDBAvailable = false; + localStorage.clear(); + } + } + + // Utility method to check storage health + public async checkHealth(): Promise<{ + indexedDB: boolean; + localStorage: boolean; + }> { + const health = { + indexedDB: false, + localStorage: false, + }; + + // Test IndexedDB + try { + await set("health-check", "test"); + await get("health-check"); + await del("health-check"); + health.indexedDB = true; + this.isIndexedDBAvailable = true; + } catch (error) { + console.warn("[IndexedDB] Health check failed:", error); + this.isIndexedDBAvailable = false; + } + + // Test localStorage + try { + localStorage.setItem("health-check", "test"); + localStorage.getItem("health-check"); + localStorage.removeItem("health-check"); + health.localStorage = true; + } catch (error) { + console.warn("[IndexedDB] localStorage health check failed:", error); + } + + return health; + } + + // Method to migrate data from localStorage to IndexedDB + public async migrateFromLocalStorage(): Promise { + if (!this.isIndexedDBAvailable) { + console.warn("[IndexedDB] Cannot migrate - IndexedDB unavailable"); + return; + } + + try { + // Get all store keys from constants + const storeKeys = [ + "chat-next-web-store", + "chat-next-web-plugin", + "access-control", + "app-config", + "mask-store", + "prompt-store", + "chat-update", + "sync", + "sd-list", + "mcp-store", + ]; + + for (const key of storeKeys) { + const localValue = localStorage.getItem(key); + if (localValue) { + try { + // Check if already exists in IndexedDB + const existingValue = await get(key); + if (!existingValue) { + await set(key, localValue); + console.log( + `[IndexedDB] Migrated ${key} from localStorage to IndexedDB`, + ); + } + } catch (error) { + console.error(`[IndexedDB] Failed to migrate ${key}:`, error); + } + } + } + } catch (error) { + console.error("[IndexedDB] Migration failed:", error); } } } diff --git a/app/utils/storage-debug.ts b/app/utils/storage-debug.ts new file mode 100644 index 000000000..1ceaf0a0d --- /dev/null +++ b/app/utils/storage-debug.ts @@ -0,0 +1,236 @@ +import { indexedDBStorage } from "./indexedDB-storage"; +import { StoreKey } from "../constant"; + +// Storage debugging utilities +export class StorageDebugger { + static async checkStorageHealth() { + console.log("🔍 Checking storage health..."); + const health = await indexedDBStorage.checkHealth(); + + console.log("📊 Storage Health Report:"); + console.log( + ` - IndexedDB: ${health.indexedDB ? "✅ Available" : "❌ Unavailable"}`, + ); + console.log( + ` - localStorage: ${ + health.localStorage ? "✅ Available" : "❌ Unavailable" + }`, + ); + + return health; + } + + static async listAllStoreData() { + console.log("📋 Listing all store data..."); + + const storeKeys = Object.values(StoreKey); + const storeData: Record = {}; + + for (const key of storeKeys) { + try { + const data = await indexedDBStorage.getItem(key); + if (data) { + const parsed = JSON.parse(data); + storeData[key] = { + size: data.length, + hasState: !!parsed.state, + hasHydrated: parsed.state?._hasHydrated || false, + lastUpdateTime: parsed.state?.lastUpdateTime || 0, + keys: Object.keys(parsed.state || {}), + }; + + // Special handling for chat store + if (key === StoreKey.Chat && parsed.state?.sessions) { + storeData[key].sessionCount = parsed.state.sessions.length; + storeData[key].currentSessionIndex = + parsed.state.currentSessionIndex; + } + } else { + storeData[key] = null; + } + } catch (error) { + console.error(`Error reading store ${key}:`, error); + storeData[key] = { + error: error instanceof Error ? error.message : String(error), + }; + } + } + + console.table(storeData); + return storeData; + } + + static async migrateData() { + console.log("🔄 Starting data migration..."); + await indexedDBStorage.migrateFromLocalStorage(); + console.log("✅ Migration completed"); + } + + static async clearStore(storeKey: StoreKey) { + console.log(`🗑️ Clearing store: ${storeKey}`); + try { + await indexedDBStorage.removeItem(storeKey); + console.log(`✅ Store ${storeKey} cleared successfully`); + } catch (error) { + console.error(`❌ Failed to clear store ${storeKey}:`, error); + } + } + + static async clearAllStores() { + console.log("🗑️ Clearing all stores..."); + try { + await indexedDBStorage.clear(); + console.log("✅ All stores cleared successfully"); + } catch (error) { + console.error("❌ Failed to clear all stores:", error); + } + } + + static async backupStores() { + console.log("💾 Creating backup of all stores..."); + + const backup: Record = { + timestamp: new Date().toISOString(), + stores: {}, + }; + + const storeKeys = Object.values(StoreKey); + + for (const key of storeKeys) { + try { + const data = await indexedDBStorage.getItem(key); + if (data) { + backup.stores[key] = data; + } + } catch (error) { + console.error(`Error backing up store ${key}:`, error); + backup.stores[key] = { + error: error instanceof Error ? error.message : String(error), + }; + } + } + + // Save backup to localStorage as well + try { + const backupString = JSON.stringify(backup); + localStorage.setItem("store-backup", backupString); + console.log("💾 Backup saved to localStorage"); + + // Also download as file + const blob = new Blob([backupString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `store-backup-${ + new Date().toISOString().split("T")[0] + }.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log("💾 Backup downloaded as file"); + } catch (error) { + console.error("❌ Failed to save backup:", error); + } + + return backup; + } + + static async restoreFromBackup(backupData: any) { + console.log("♻️ Restoring from backup..."); + + if (!backupData || !backupData.stores) { + throw new Error("Invalid backup data"); + } + + for (const [key, data] of Object.entries(backupData.stores)) { + if (typeof data === "string") { + try { + await indexedDBStorage.setItem(key, data); + console.log(`✅ Restored store: ${key}`); + } catch (error) { + console.error(`❌ Failed to restore store ${key}:`, error); + } + } + } + + console.log("♻️ Restore completed"); + } + + static async validateStoreIntegrity() { + console.log("🔍 Validating store integrity..."); + + const issues: string[] = []; + const storeKeys = Object.values(StoreKey); + + for (const key of storeKeys) { + try { + const data = await indexedDBStorage.getItem(key); + if (data) { + const parsed = JSON.parse(data); + + // Basic structure validation + if (!parsed.state) { + issues.push(`${key}: Missing state object`); + } + + if ( + parsed.state && + typeof parsed.state._hasHydrated === "undefined" + ) { + issues.push(`${key}: Missing _hasHydrated flag`); + } + + // Store-specific validation + if (key === StoreKey.Chat) { + if ( + !parsed.state?.sessions || + !Array.isArray(parsed.state.sessions) + ) { + issues.push(`${key}: Invalid or missing sessions array`); + } + + if (typeof parsed.state?.currentSessionIndex !== "number") { + issues.push(`${key}: Invalid currentSessionIndex`); + } + } + } + } catch (error) { + issues.push( + `${key}: JSON parse error - ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + if (issues.length === 0) { + console.log("✅ All stores have valid integrity"); + } else { + console.warn("⚠️ Store integrity issues found:"); + issues.forEach((issue) => console.warn(` - ${issue}`)); + } + + return issues; + } +} + +// Global debugging functions for console use +if (typeof window !== "undefined") { + (window as any).debugStorage = StorageDebugger; + + // Add helpful console messages + console.log(` +🔧 Storage Debug Utils Available: +- debugStorage.checkStorageHealth() +- debugStorage.listAllStoreData() +- debugStorage.migrateData() +- debugStorage.clearStore(StoreKey.Chat) +- debugStorage.clearAllStores() +- debugStorage.backupStores() +- debugStorage.validateStoreIntegrity() + +Example: debugStorage.listAllStoreData() + `); +} diff --git a/app/utils/storage-migration.ts b/app/utils/storage-migration.ts new file mode 100644 index 000000000..b345a0f6b --- /dev/null +++ b/app/utils/storage-migration.ts @@ -0,0 +1,319 @@ +import { StoreKey } from "../constant"; +import { indexedDBStorage } from "./indexedDB-storage"; +import { safeLocalStorage } from "../utils"; + +const localStorage = safeLocalStorage(); + +export class StorageMigration { + /** + * Initialize storage system and perform necessary migrations + */ + static async initialize(): Promise { + console.log("[StorageMigration] Initializing storage system..."); + + try { + // Check storage health + const health = await indexedDBStorage.checkHealth(); + + if (health.indexedDB) { + console.log("[StorageMigration] IndexedDB available"); + + // Attempt to migrate data from localStorage if needed + await this.migrateFromLocalStorageIfNeeded(); + } else if (health.localStorage) { + console.log( + "[StorageMigration] IndexedDB unavailable, using localStorage", + ); + } else { + console.error("[StorageMigration] No storage available!"); + throw new Error("No storage mechanism available"); + } + + // Validate existing data + await this.validateAndRepairStores(); + } catch (error) { + console.error("[StorageMigration] Initialization failed:", error); + throw error; + } + } + + /** + * Migrate data from localStorage to IndexedDB if needed + */ + private static async migrateFromLocalStorageIfNeeded(): Promise { + const storeKeys = Object.values(StoreKey); + let migratedCount = 0; + + for (const key of storeKeys) { + try { + // Check if data exists in localStorage but not in IndexedDB + const localData = localStorage.getItem(key); + const indexedData = await indexedDBStorage.getItem(key); + + if (localData && !indexedData) { + // Validate the data before migration + try { + const parsed = JSON.parse(localData); + if (parsed && typeof parsed === "object") { + await indexedDBStorage.setItem(key, localData); + migratedCount++; + console.log( + `[StorageMigration] Migrated ${key} from localStorage to IndexedDB`, + ); + } + } catch (parseError) { + console.warn( + `[StorageMigration] Skipping invalid data for ${key}:`, + parseError, + ); + } + } + } catch (error) { + console.error(`[StorageMigration] Failed to migrate ${key}:`, error); + } + } + + if (migratedCount > 0) { + console.log( + `[StorageMigration] Successfully migrated ${migratedCount} stores`, + ); + } + } + + /** + * Validate and repair store data if necessary + */ + private static async validateAndRepairStores(): Promise { + const storeKeys = Object.values(StoreKey); + + for (const key of storeKeys) { + try { + const data = await indexedDBStorage.getItem(key); + if (data) { + const repaired = await this.repairStoreData(key, data); + if (repaired && repaired !== data) { + await indexedDBStorage.setItem(key, repaired); + console.log(`[StorageMigration] Repaired data for ${key}`); + } + } + } catch (error) { + console.error(`[StorageMigration] Failed to validate ${key}:`, error); + } + } + } + + /** + * Repair store data if it has known issues + */ + private static async repairStoreData( + storeKey: string, + data: string, + ): Promise { + try { + const parsed = JSON.parse(data); + let modified = false; + + // Ensure basic structure exists + if (!parsed.state) { + parsed.state = {}; + modified = true; + } + + // Ensure _hasHydrated flag exists + if (typeof parsed.state._hasHydrated === "undefined") { + parsed.state._hasHydrated = false; + modified = true; + } + + // Ensure lastUpdateTime exists + if (typeof parsed.state.lastUpdateTime === "undefined") { + parsed.state.lastUpdateTime = Date.now(); + modified = true; + } + + // Store-specific repairs + switch (storeKey) { + case StoreKey.Chat: + if (this.repairChatStore(parsed.state)) { + modified = true; + } + break; + + case StoreKey.Config: + if (this.repairConfigStore(parsed.state)) { + modified = true; + } + break; + + case StoreKey.Access: + if (this.repairAccessStore(parsed.state)) { + modified = true; + } + break; + } + + return modified ? JSON.stringify(parsed) : null; + } catch (error) { + console.error(`[StorageMigration] Failed to repair ${storeKey}:`, error); + return null; + } + } + + /** + * Repair chat store specific issues + */ + private static repairChatStore(state: any): boolean { + let modified = false; + + // Ensure sessions array exists + if (!Array.isArray(state.sessions)) { + state.sessions = []; + modified = true; + } + + // Ensure currentSessionIndex is valid + if ( + typeof state.currentSessionIndex !== "number" || + state.currentSessionIndex < 0 || + state.currentSessionIndex >= state.sessions.length + ) { + state.currentSessionIndex = 0; + modified = true; + } + + // Ensure each session has required properties + state.sessions.forEach((session: any, index: number) => { + if (!session.id) { + session.id = `session-${Date.now()}-${index}`; + modified = true; + } + + if (!Array.isArray(session.messages)) { + session.messages = []; + modified = true; + } + + if (!session.mask) { + session.mask = { modelConfig: {} }; + modified = true; + } + + if (typeof session.lastUpdate !== "number") { + session.lastUpdate = Date.now(); + modified = true; + } + }); + + return modified; + } + + /** + * Repair config store specific issues + */ + private static repairConfigStore(state: any): boolean { + let modified = false; + + // Ensure basic config properties exist + if (!state.theme) { + state.theme = "auto"; + modified = true; + } + + if (!state.models || !Array.isArray(state.models)) { + state.models = []; + modified = true; + } + + return modified; + } + + /** + * Repair access store specific issues + */ + private static repairAccessStore(state: any): boolean { + let modified = false; + + // Ensure access code exists + if (typeof state.accessCode !== "string") { + state.accessCode = ""; + modified = true; + } + + return modified; + } + + /** + * Clear corrupted stores and reset to defaults + */ + static async clearCorruptedStores(): Promise { + console.log("[StorageMigration] Clearing corrupted stores..."); + + const storeKeys = Object.values(StoreKey); + + for (const key of storeKeys) { + try { + const data = await indexedDBStorage.getItem(key); + if (data) { + try { + JSON.parse(data); + } catch (parseError) { + console.warn(`[StorageMigration] Clearing corrupted store ${key}`); + await indexedDBStorage.removeItem(key); + } + } + } catch (error) { + console.error(`[StorageMigration] Error checking ${key}:`, error); + } + } + } + + /** + * Create a backup before performing migrations + */ + static async createPreMigrationBackup(): Promise { + console.log("[StorageMigration] Creating pre-migration backup..."); + + const backup: Record = { + timestamp: new Date().toISOString(), + type: "pre-migration", + stores: {}, + }; + + const storeKeys = Object.values(StoreKey); + + for (const key of storeKeys) { + try { + const localData = localStorage.getItem(key); + const indexedData = await indexedDBStorage.getItem(key); + + backup.stores[key] = { + localStorage: localData, + indexedDB: indexedData, + }; + } catch (error) { + console.error(`[StorageMigration] Error backing up ${key}:`, error); + backup.stores[key] = { + error: error instanceof Error ? error.message : String(error), + }; + } + } + + const backupString = JSON.stringify(backup); + localStorage.setItem("pre-migration-backup", backupString); + + console.log("[StorageMigration] Backup created successfully"); + return backupString; + } +} + +// Auto-initialize when module is loaded (client-side only) +if (typeof window !== "undefined") { + // Wait for the page to load before initializing + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + StorageMigration.initialize().catch(console.error); + }); + } else { + StorageMigration.initialize().catch(console.error); + } +} diff --git a/app/utils/store.ts b/app/utils/store.ts index 04a1d6fcf..ca7e42f7a 100644 --- a/app/utils/store.ts +++ b/app/utils/store.ts @@ -47,7 +47,35 @@ export function createPersistStore( // Gán lại hàm onRehydrateStorage để đánh dấu đã hydrate khi khôi phục dữ liệu persistOptions.onRehydrateStorage = (state) => { oldOonRehydrateStorage?.(state); - return () => state.setHasHydrated(true); + return async (state, error) => { + if (error) { + console.error( + `[Store] Hydration failed for ${persistOptions.name}:`, + error, + ); + } else { + console.log(`[Store] Successfully hydrated ${persistOptions.name}`); + + // Check IndexedDB health on first hydration + if (typeof window !== "undefined") { + try { + const { indexedDBStorage } = await import("./indexedDB-storage"); + const health = await indexedDBStorage.checkHealth(); + if (!health.indexedDB) { + console.warn( + `[Store] IndexedDB unavailable for ${persistOptions.name}, using localStorage fallback`, + ); + } + } catch (err) { + console.warn(`[Store] Storage health check failed:`, err); + } + } + } + + if (state) { + state.setHasHydrated(true); + } + }; }; // Tạo store với zustand, kết hợp các middleware và phương thức bổ sung diff --git a/git.sh b/git.sh index 0ffa7ba54..fa98bc62c 100644 --- a/git.sh +++ b/git.sh @@ -2,7 +2,7 @@ # git config --global user.name "quangdn-ght" git add . -git commit -m "loi he thong" +git commit -m "cap nhat auth he thong de nhung voi chebichat" git push # mdZddHXcuzsB0Akk \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 000000000..90a173523 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkAuthWithRefresh } from "./app/api/supabase"; + +// Define protected routes that require authentication +const PROTECTED_PATHS = [ + "/chat", + "/settings", + "/profile", + "/api/chat", + "/api/user", + // Add more protected paths as needed +]; + +// Define public routes that don't require authentication +const PUBLIC_PATHS = [ + "/", + "/login", + "/signup", + "/api/auth/callback", + "/api/auth/logout", + "/api/auth/check", + // Add more public paths as needed +]; + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + + console.log("[Middleware] Processing request for:", pathname); + + // Skip middleware for static files and Next.js internals + if ( + pathname.startsWith("/_next/") || + pathname.startsWith("/favicon") || + pathname.startsWith("/public/") || + pathname.includes(".") + ) { + return NextResponse.next(); + } + + // Check if path is explicitly public + const isPublicPath = PUBLIC_PATHS.some(path => + pathname === path || pathname.startsWith(path) + ); + + if (isPublicPath) { + console.log("[Middleware] Public path, allowing access"); + return NextResponse.next(); + } + + // Check if path requires authentication + const isProtectedPath = PROTECTED_PATHS.some(path => + pathname.startsWith(path) || pathname === path + ); + + if (isProtectedPath) { + console.log("[Middleware] Protected path, checking authentication"); + + try { + const authResult = await checkAuthWithRefresh(req); + + if (!authResult.user) { + console.log("[Middleware] User not authenticated, redirecting to login"); + const loginUrl = new URL("/login", req.url); + loginUrl.searchParams.set("redirect_to", pathname); + return NextResponse.redirect(loginUrl); + } + + console.log("[Middleware] User authenticated:", authResult.user.email); + + // If token was refreshed, return the response with updated cookies + if (authResult.needsRefresh && authResult.response) { + console.log("[Middleware] Returning response with refreshed tokens"); + // Continue to the original destination + authResult.response.headers.set("x-middleware-rewrite", req.url); + return authResult.response; + } + + return NextResponse.next(); + } catch (error) { + console.error("[Middleware] Auth check failed:", error); + const loginUrl = new URL("/login", req.url); + loginUrl.searchParams.set("redirect_to", pathname); + loginUrl.searchParams.set("error", "auth_check_failed"); + return NextResponse.redirect(loginUrl); + } + } + + // For all other paths, allow access without authentication + console.log("[Middleware] Unprotected path, allowing access"); + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api/auth (auth routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + "/((?!_next/static|_next/image|favicon.ico).*)", + ], +}; diff --git a/run.sh b/run.sh index 5a57bb434..1483c9677 100644 --- a/run.sh +++ b/run.sh @@ -1,2 +1,2 @@ # yarn build && pm2 start "yarn start" --name "chebi-nextjs" -i max -pm2 start "yarn start" --name "chebi-nextjs" -i max \ No newline at end of file +pm2 start "yarn start" --name "chebi-nextjs" \ No newline at end of file diff --git a/test_auth.sh b/test_auth.sh new file mode 100755 index 000000000..35bd516ce --- /dev/null +++ b/test_auth.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# Test Script for Authentication System +# This script demonstrates how to test the authentication endpoints + +echo "🔐 Authentication System Test Script" +echo "=====================================" +echo "" + +# Base URL (adjust as needed) +BASE_URL="http://localhost:3000" + +# Test token (replace with a real Supabase token for actual testing) +TEST_TOKEN="eyJhbGciOiJIUzI1NiIsImtpZCI6InhXR2xGVUY3KytFRmdqN2siLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3p6Z2t5bHNiZGd3b29oY2JvbXBpLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiIyZjg4NzZlMy01NGYxLTQ3ODUtODFlMC0zYzcyMmYyM2E4YTkiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzUyNDkyMTU2LCJpYXQiOjE3NTE4ODczNTYsImVtYWlsIjoicXVhbmdkbkBnaWFodW5ndGVjaC5jb20udm4iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6Imdvb2dsZSIsInByb3ZpZGVycyI6WyJnb29nbGUiXX0sInVzZXJfbWV0YWRhdGEiOnsiYXZhdGFyX3VybCI6Imh0dHBzOi8venpna3lsc2JkZ3dvb2hjYm9tcGkuc3VwYWJhc2UuY28vc3RvcmFnZS92MS9vYmplY3QvcHVibGljL2F2YXRhcnMvMmY4ODc2ZTMtNTRmMS00Nzg1LTgxZTAtM2M3MjJmMjNhOGE5L2F2YXRhci5qcGciLCJjdXN0b21fY2xhaW1zIjp7ImhkIjoiZ2lhaHVuZ3RlY2guY29tLnZuIn0sImVtYWlsIjoicXVhbmdkbkBnaWFodW5ndGVjaC5jb20udm4iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTmjhuq10IFF1YW5nIMSQ4buXIiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwibmFtZSI6Ik5o4bqtdCBRdWFuZyDEkOG7lyIsInBob25lX3ZlcmlmaWVkIjpmYWxzZSwicHJvdmlkZXJfaWQiOiIxMDY3ODcyNzQyMTEwMDM2Mjc3ODUiLCJzdWIiOiIxMDY3ODcyNzQyMTEwMDM2Mjc3ODUifSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJvYXV0aCIsInRpbWVzdGFtcCI6MTc1MTg4NzM1Nn1dLCJzZXNzaW9uX2lkIjoiY2ZlZjBhMzctZDdjYi00OWQwLWExODEtOTA1YjIxNzk5Y2ZhIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.vKSX_n5HazYDNFpNa-HUjtY9wWrb7bWPsrhDtEzXcDg" +REFRESH_TOKEN="your_supabase_refresh_token_here" + +echo "📋 Testing Authentication Endpoints..." +echo "" + +# Test 1: Check auth status (should be unauthenticated) +echo "1️⃣ Testing auth check (should be unauthenticated):" +curl -s -X GET "$BASE_URL/api/auth/check" \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + | jq '.' || echo "Response: Not authenticated (expected)" +echo "" + +# Test 2: Login via POST (requires real token) +echo "2️⃣ Testing login via POST:" +echo " (Replace TEST_TOKEN with real Supabase token)" +curl -s -X POST "$BASE_URL/api/auth/callback" \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d "{ + \"access_token\": \"$TEST_TOKEN\", + \"refresh_token\": \"$REFRESH_TOKEN\" + }" \ + | jq '.' || echo "Response: Login attempt (requires valid token)" +echo "" + +# Test 3: Check auth status after login +echo "3️⃣ Testing auth check after login:" +curl -s -X GET "$BASE_URL/api/auth/check" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + | jq '.' || echo "Response: Auth check after login" +echo "" + +# Test 4: Access protected user endpoint +echo "4️⃣ Testing protected user endpoint:" +curl -s -X GET "$BASE_URL/api/user" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + | jq '.' || echo "Response: User info (requires authentication)" +echo "" + +# Test 5: Test token refresh +echo "5️⃣ Testing token refresh:" +curl -s -X POST "$BASE_URL/api/auth/refresh" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + | jq '.' || echo "Response: Token refresh attempt" +echo "" + +# Test 6: Logout +echo "6️⃣ Testing logout:" +curl -s -X POST "$BASE_URL/api/auth/logout" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -c cookies.txt \ + | jq '.' || echo "Response: Logout successful" +echo "" + +# Test 7: Check auth status after logout +echo "7️⃣ Testing auth check after logout:" +curl -s -X GET "$BASE_URL/api/auth/check" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + | jq '.' || echo "Response: Should be unauthenticated" +echo "" + +echo "✅ Authentication system test completed!" +echo "" +echo "📝 Manual Tests:" +echo " 1. Visit: $BASE_URL/login" +echo " 2. Visit: $BASE_URL/profile (should redirect to login)" +echo " 3. Login with token, then visit: $BASE_URL/profile" +echo " 4. Test callback URL: $BASE_URL/api/auth/callback?token=YOUR_TOKEN" +echo "" +echo "🔧 To test with real tokens:" +echo " 1. Replace TEST_TOKEN and REFRESH_TOKEN variables" +echo " 2. Get tokens from your Supabase authentication flow" +echo " 3. Run this script again" +echo "" + +# Cleanup +rm -f cookies.txt +echo "🧹 Cleaned up temporary files" diff --git a/test_indexeddb.sh b/test_indexeddb.sh new file mode 100644 index 000000000..b3549d123 --- /dev/null +++ b/test_indexeddb.sh @@ -0,0 +1,176 @@ +#!/bin/bash + +# IndexedDB Storage Testing Script +# This script provides comprehensive testing for the IndexedDB storage fixes + +echo "🗄️ IndexedDB Storage Testing Script" +echo "====================================" +echo "" + +# Function to print section headers +print_section() { + echo "" + echo "📋 $1" + echo "----------------------------------------" +} + +# Function to run a test and capture results +run_test() { + local test_name="$1" + local test_command="$2" + + echo "🧪 Testing: $test_name" + echo "Command: $test_command" + + # Execute the test (this would typically be run in browser console) + echo " ⚠️ Run this in browser console: $test_command" + echo "" +} + +print_section "Test Environment Setup" +echo "1. Start the development server:" +echo " npm run dev" +echo "" +echo "2. Open browser and go to: http://localhost:3000" +echo "" +echo "3. Open browser developer tools (F12)" +echo "" +echo "4. Go to Console tab" +echo "" + +print_section "IndexedDB Health Check Tests" +run_test "Check Storage Health" "await debugStorage.checkStorageHealth()" +run_test "List All Store Data" "await debugStorage.listAllStoreData()" +run_test "Validate Store Integrity" "await debugStorage.validateStoreIntegrity()" + +print_section "Chat Store Specific Tests" +echo "🧪 Chat Store Tests (run in console):" +echo "" +echo "// Test 1: Check if chat store exists and is properly structured" +echo "const chatData = await debugStorage.listAllStoreData();" +echo "console.log('Chat Store:', chatData['chat-next-web-store']);" +echo "" +echo "// Test 2: Create a new chat session" +echo "const { useChatStore } = await import('/app/store/chat');" +echo "const chatStore = useChatStore.getState();" +echo "console.log('Current sessions:', chatStore.sessions.length);" +echo "console.log('Current session index:', chatStore.currentSessionIndex);" +echo "" +echo "// Test 3: Add a message and verify persistence" +echo "chatStore.onUserInput('Test message for IndexedDB');" +echo "await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for persistence" +echo "await debugStorage.listAllStoreData(); // Check if saved" +echo "" + +print_section "Storage Migration Tests" +run_test "Migrate Data" "await debugStorage.migrateData()" +run_test "Create Backup" "await debugStorage.backupStores()" + +print_section "Error Handling Tests" +echo "🧪 Error Handling Tests (run in console):" +echo "" +echo "// Test 1: Simulate IndexedDB failure" +echo "// (This requires manually disabling IndexedDB in browser settings)" +echo "" +echo "// Test 2: Test with corrupted data" +echo "localStorage.setItem('test-corrupt', '{invalid json}');" +echo "const testStorage = await import('/app/utils/indexedDB-storage');" +echo "try {" +echo " await testStorage.indexedDBStorage.getItem('test-corrupt');" +echo "} catch (error) {" +echo " console.log('Handled corrupted data:', error);" +echo "}" +echo "" + +print_section "Store Key Consistency Tests" +echo "🧪 Store Key Tests (run in console):" +echo "" +echo "// Test 1: Verify all store keys are accessible" +echo "const { StoreKey } = await import('/app/constant');" +echo "console.log('Available Store Keys:', Object.values(StoreKey));" +echo "" +echo "// Test 2: Check each store exists" +echo "for (const key of Object.values(StoreKey)) {" +echo " const data = await debugStorage.indexedDBStorage.getItem(key);" +echo " console.log(\`Store \${key}: \${data ? 'EXISTS' : 'MISSING'}\`);" +echo "}" +echo "" + +print_section "Performance Tests" +echo "🧪 Performance Tests (run in console):" +echo "" +echo "// Test 1: Large data storage performance" +echo "const largeData = { state: { messages: new Array(1000).fill({id: 'test', content: 'test message', date: new Date().toISOString()}), _hasHydrated: true } };" +echo "console.time('Large data storage');" +echo "await debugStorage.indexedDBStorage.setItem('performance-test', JSON.stringify(largeData));" +echo "console.timeEnd('Large data storage');" +echo "" +echo "// Test 2: Retrieval performance" +echo "console.time('Large data retrieval');" +echo "const retrieved = await debugStorage.indexedDBStorage.getItem('performance-test');" +echo "console.timeEnd('Large data retrieval');" +echo "console.log('Retrieved data size:', retrieved?.length || 0, 'characters');" +echo "" + +print_section "Cleanup Tests" +run_test "Clear Test Data" "await debugStorage.indexedDBStorage.removeItem('performance-test')" +run_test "Clear All Stores (CAREFUL!)" "await debugStorage.clearAllStores()" + +print_section "Manual Testing Checklist" +echo "✅ Manual Tests to Perform:" +echo "" +echo "1. Chat Functionality:" +echo " - Create a new chat session" +echo " - Send multiple messages" +echo " - Refresh the page" +echo " - Verify messages persist" +echo "" +echo "2. Settings Persistence:" +echo " - Change app settings" +echo " - Refresh the page" +echo " - Verify settings persist" +echo "" +echo "3. Cross-tab Sync:" +echo " - Open app in multiple tabs" +echo " - Make changes in one tab" +echo " - Verify changes appear in other tabs" +echo "" +echo "4. Storage Fallback:" +echo " - Disable IndexedDB in browser settings" +echo " - Refresh the page" +echo " - Verify app still works with localStorage" +echo "" + +print_section "Expected Results" +echo "✅ Success Indicators:" +echo " - No console errors related to storage" +echo " - Chat messages persist across page refreshes" +echo " - IndexedDB health check returns { indexedDB: true, localStorage: true }" +echo " - Store integrity validation returns empty issues array" +echo " - All store keys have valid data structure" +echo "" +echo "❌ Failure Indicators:" +echo " - 'skip setItem' warnings in console" +echo " - Messages lost after page refresh" +echo " - Health check shows IndexedDB: false" +echo " - Store integrity issues found" +echo " - JSON parse errors in console" +echo "" + +print_section "Troubleshooting" +echo "🔧 Common Issues & Solutions:" +echo "" +echo "Issue: 'skip setItem' warnings" +echo "Solution: Fixed with improved hydration logic" +echo "" +echo "Issue: Data not persisting" +echo "Solution: Check _hasHydrated flag and storage availability" +echo "" +echo "Issue: IndexedDB unavailable" +echo "Solution: App should fallback to localStorage automatically" +echo "" +echo "Issue: Corrupted data" +echo "Solution: Use debugStorage.clearStore(StoreKey.Chat) to reset" +echo "" + +echo "🎯 Test completed! Review results in browser console."