mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-09-26 21:26:37 +08:00
cap nhat auth he thong de nhung voi chebichat
This commit is contained in:
parent
972f957633
commit
b42c5154e3
259
AUTH_SYSTEM.md
Normal file
259
AUTH_SYSTEM.md
Normal file
@ -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 <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => login("ACCESS_TOKEN", "REFRESH_TOKEN")}>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Welcome, {user?.email}!</p>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Protecting Routes with HOC
|
||||
|
||||
```tsx
|
||||
import { withAuth } from "../hooks/useAuth";
|
||||
|
||||
function ProtectedPage({ user }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Protected Content</h1>
|
||||
<p>User ID: {user.id}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
174
app/api/auth/callback/route.ts
Normal file
174
app/api/auth/callback/route.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
68
app/api/auth/logout/route.ts
Normal file
68
app/api/auth/logout/route.ts
Normal file
@ -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;
|
||||
}
|
99
app/api/auth/refresh/route.ts
Normal file
99
app/api/auth/refresh/route.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
102
app/api/user/route.ts
Normal file
102
app/api/user/route.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
220
app/auth-demo/page.tsx
Normal file
220
app/auth-demo/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading authentication status...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 mb-8">
|
||||
🔐 Authentication System Demo
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Authentication Status Card */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Authentication Status
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-700 w-24">
|
||||
Status:
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
authenticated
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{authenticated ? "✅ Authenticated" : "❌ Not Authenticated"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{user?.id && (
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-700 w-24">
|
||||
User ID:
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 font-mono">
|
||||
{user.id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.email && (
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-700 w-24">
|
||||
Email:
|
||||
</span>
|
||||
<span className="text-sm text-gray-900">{user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authenticated && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="block w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
🔑 Login Page
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/profile"
|
||||
className="block w-full text-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
👤 Profile Page (Protected)
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/api/auth/check"
|
||||
className="block w-full text-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
✅ Check Auth API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Flow Card */}
|
||||
<div className="bg-white shadow rounded-lg p-6 lg:col-span-2">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
🔄 Authentication Flow
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">
|
||||
1. Token Authentication via URL
|
||||
</h3>
|
||||
<code className="text-xs text-gray-600 bg-white p-2 rounded block">
|
||||
GET
|
||||
/api/auth/callback?token=YOUR_SUPABASE_TOKEN&refresh_token=REFRESH_TOKEN&redirect_to=/profile
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">
|
||||
2. Programmatic Login via POST
|
||||
</h3>
|
||||
<code className="text-xs text-gray-600 bg-white p-2 rounded block">
|
||||
POST /api/auth/callback
|
||||
<br />
|
||||
{`{"access_token": "token", "refresh_token": "refresh"}`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">
|
||||
3. Manual Token Entry
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
Visit the{" "}
|
||||
<a
|
||||
href="/login"
|
||||
className="text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
login page
|
||||
</a>{" "}
|
||||
to enter your Supabase tokens manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Endpoints Card */}
|
||||
<div className="bg-white shadow rounded-lg p-6 lg:col-span-2">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
🚀 Available API Endpoints
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
Authentication
|
||||
</h3>
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
<li>
|
||||
<code>GET /api/auth/check</code> - Check auth status
|
||||
</li>
|
||||
<li>
|
||||
<code>POST /api/auth/callback</code> - Login with tokens
|
||||
</li>
|
||||
<li>
|
||||
<code>POST /api/auth/refresh</code> - Refresh access token
|
||||
</li>
|
||||
<li>
|
||||
<code>POST /api/auth/logout</code> - Logout and clear
|
||||
cookies
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
Protected Routes
|
||||
</h3>
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
<li>
|
||||
<code>GET /api/user</code> - Get user information
|
||||
</li>
|
||||
<li>
|
||||
<code>GET /profile</code> - User profile page
|
||||
</li>
|
||||
<li>
|
||||
<code>GET /chat</code> - Chat interface (if implemented)
|
||||
</li>
|
||||
<li>
|
||||
<code>GET /settings</code> - Settings page (if implemented)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
📚 See <code>AUTH_SYSTEM.md</code> for detailed documentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
||||
|
29
app/components/storage-initializer.tsx
Normal file
29
app/components/storage-initializer.tsx
Normal file
@ -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
|
||||
}
|
@ -102,6 +102,7 @@ export enum StoreKey {
|
||||
Sync = "sync",
|
||||
SdList = "sd-list",
|
||||
Mcp = "mcp-store",
|
||||
Auth = "auth-store",
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||
|
227
app/hooks/useAuth.ts
Normal file
227
app/hooks/useAuth.ts
Normal file
@ -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<AuthState>({
|
||||
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<T extends Record<string, any>>(
|
||||
Component: React.ComponentType<T>,
|
||||
) {
|
||||
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;
|
||||
}
|
217
app/hooks/useAuth.tsx
Normal file
217
app/hooks/useAuth.tsx
Normal file
@ -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<AuthState>({
|
||||
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<T extends object>(Component: React.ComponentType<T>) {
|
||||
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 <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return <div>Redirecting to login...</div>;
|
||||
}
|
||||
|
||||
return <Component {...props} user={user} />;
|
||||
};
|
||||
}
|
@ -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({
|
||||
<body>
|
||||
{children}
|
||||
|
||||
{/* Storage System Initialization */}
|
||||
<StorageInitializer />
|
||||
|
||||
{serverConfig?.isVercel && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
|
150
app/login/page.tsx
Normal file
150
app/login/page.tsx
Normal file
@ -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 <div>Redirecting...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your Supabase authentication token
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="token"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Access Token *
|
||||
</label>
|
||||
<input
|
||||
id="token"
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Enter your access token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="refreshToken"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Refresh Token (optional)
|
||||
</label>
|
||||
<input
|
||||
id="refreshToken"
|
||||
name="refreshToken"
|
||||
type="text"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Enter your refresh token (optional)"
|
||||
value={refreshToken}
|
||||
onChange={(e) => setRefreshToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
You can also authenticate by visiting:{" "}
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs">
|
||||
/api/auth/callback?token=YOUR_TOKEN
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
74
app/profile/page.tsx
Normal file
74
app/profile/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Profile Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
User ID
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{user?.id}</p>
|
||||
</div>
|
||||
|
||||
{user?.email && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.user_metadata &&
|
||||
Object.keys(user.user_metadata).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
User Metadata
|
||||
</label>
|
||||
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded">
|
||||
{JSON.stringify(user.user_metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex space-x-3">
|
||||
<button
|
||||
onClick={() => (window.location.href = "/")}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuth(ProfilePage);
|
@ -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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
236
app/utils/storage-debug.ts
Normal file
236
app/utils/storage-debug.ts
Normal file
@ -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<string, any> = {};
|
||||
|
||||
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<string, any> = {
|
||||
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()
|
||||
`);
|
||||
}
|
319
app/utils/storage-migration.ts
Normal file
319
app/utils/storage-migration.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
console.log("[StorageMigration] Creating pre-migration backup...");
|
||||
|
||||
const backup: Record<string, any> = {
|
||||
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);
|
||||
}
|
||||
}
|
@ -47,7 +47,35 @@ export function createPersistStore<T extends object, M>(
|
||||
// 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
|
||||
|
2
git.sh
2
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
|
104
middleware.ts
Normal file
104
middleware.ts
Normal file
@ -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).*)",
|
||||
],
|
||||
};
|
2
run.sh
2
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
|
||||
pm2 start "yarn start" --name "chebi-nextjs"
|
98
test_auth.sh
Executable file
98
test_auth.sh
Executable file
@ -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"
|
176
test_indexeddb.sh
Normal file
176
test_indexeddb.sh
Normal file
@ -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."
|
Loading…
Reference in New Issue
Block a user