mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2026-02-11 08:54:29 +08:00
cap nhat auth he thong de nhung voi chebichat
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user