Compare commits
4 Commits
c47c43dc92
...
main
Author | SHA1 | Date | |
---|---|---|---|
7d7ed00c22 | |||
42b07ea2da | |||
476d6c91b4 | |||
6a6c0934d5 |
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"jsxSingleQuote": true,
|
"jsxSingleQuote": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all",
|
||||||
|
"useTabs": true
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "next lint && tsc --noEmit",
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbo",
|
||||||
"dev:turbo": "next dev --turbo",
|
"dev:slow": "next dev",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const AuthSuccessPage = () => {
|
const AuthSuccessPage = () => {
|
||||||
const { refreshUserData, isAuthenticated } = useAuth();
|
const { refreshUserData } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -19,7 +19,7 @@ import {
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { forgotPassword } from '@/lib/actions';
|
import { forgotPassword } from '@/lib/actions';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
|
@ -20,7 +20,7 @@ import {
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn } from '@/lib/actions';
|
import { signIn } from '@/lib/actions';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
import { Separator } from '@/components/ui';
|
import { Separator } from '@/components/ui';
|
||||||
|
@ -7,7 +7,7 @@ import Link from 'next/link';
|
|||||||
import { signUp } from '@/lib/actions';
|
import { signUp } from '@/lib/actions';
|
||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -26,7 +26,7 @@ import {
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SignInWithApple,
|
SignInWithApple,
|
||||||
SignInWithMicrosoft
|
SignInWithMicrosoft,
|
||||||
} from '@/components/default/auth';
|
} from '@/components/default/auth';
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@ -201,8 +201,8 @@ const SignUp = () => {
|
|||||||
<span className='text-sm text-muted-foreground'>or</span>
|
<span className='text-sm text-muted-foreground'>or</span>
|
||||||
<Separator className='flex-1 bg-accent py-0.5' />
|
<Separator className='flex-1 bg-accent py-0.5' />
|
||||||
</div>
|
</div>
|
||||||
<SignInWithMicrosoft />
|
<SignInWithMicrosoft type='signUp' />
|
||||||
<SignInWithApple />
|
<SignInWithApple type='signUp' />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ThemeProvider } from '@/components/context/theme';
|
import { AuthProvider, ThemeProvider } from '@/components/context';
|
||||||
import { AuthProvider } from '@/components/context/auth';
|
|
||||||
import Navigation from '@/components/default/navigation';
|
import Navigation from '@/components/default/navigation';
|
||||||
import Footer from '@/components/default/footer';
|
import Footer from '@/components/default/footer';
|
||||||
import { Button, Toaster } from '@/components/ui';
|
import { Button, Toaster } from '@/components/ui';
|
||||||
|
@ -2,8 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { Geist } from 'next/font/google';
|
import { Geist } from 'next/font/google';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ThemeProvider } from '@/components/context/theme';
|
import { AuthProvider, ThemeProvider } from '@/components/context';
|
||||||
import { AuthProvider } from '@/components/context/auth';
|
|
||||||
import Navigation from '@/components/default/navigation';
|
import Navigation from '@/components/default/navigation';
|
||||||
import Footer from '@/components/default/footer';
|
import Footer from '@/components/default/footer';
|
||||||
import { Toaster } from '@/components/ui';
|
import { Toaster } from '@/components/ui';
|
||||||
|
@ -16,7 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
SignInSignUp,
|
SignInSignUp,
|
||||||
SignInWithApple,
|
SignInWithApple,
|
||||||
SignInWithMicrosoft
|
SignInWithMicrosoft,
|
||||||
} from '@/components/default/auth';
|
} from '@/components/default/auth';
|
||||||
|
|
||||||
const HomePage = async () => {
|
const HomePage = async () => {
|
||||||
|
195
src/components/context/Auth.tsx
Normal file
195
src/components/context/Auth.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
import React, {
|
||||||
|
type ReactNode,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
getProfile,
|
||||||
|
getSignedUrl,
|
||||||
|
getUser,
|
||||||
|
updateProfile as updateProfileAction,
|
||||||
|
} from '@/lib/hooks';
|
||||||
|
import { type User, type Profile, createClient } from '@/utils/supabase';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
user: User | null;
|
||||||
|
profile: Profile | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
updateProfile: (data: {
|
||||||
|
full_name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
|
||||||
|
refreshUserData: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
const fetchUserData = useCallback(
|
||||||
|
async (showLoading = true) => {
|
||||||
|
if (fetchingRef.current) return;
|
||||||
|
fetchingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only show loading for initial load or manual refresh
|
||||||
|
if (showLoading) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponse = await getUser();
|
||||||
|
const profileResponse = await getProfile();
|
||||||
|
|
||||||
|
if (!userResponse.success || !profileResponse.success) {
|
||||||
|
setUser(null);
|
||||||
|
setProfile(null);
|
||||||
|
setAvatarUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(userResponse.data);
|
||||||
|
setProfile(profileResponse.data);
|
||||||
|
|
||||||
|
// Get avatar URL if available
|
||||||
|
if (profileResponse.data.avatar_url) {
|
||||||
|
const avatarResponse = await getSignedUrl({
|
||||||
|
bucket: 'avatars',
|
||||||
|
url: profileResponse.data.avatar_url,
|
||||||
|
});
|
||||||
|
if (avatarResponse.success) {
|
||||||
|
setAvatarUrl(avatarResponse.data);
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Auth fetch error: ',
|
||||||
|
error instanceof Error
|
||||||
|
? `${error.message}`
|
||||||
|
: 'Failed to load user data!',
|
||||||
|
);
|
||||||
|
if (!isInitialized) {
|
||||||
|
toast.error('Failed to load user data!');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
|
fetchingRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isInitialized],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
// Initial fetch with loading
|
||||||
|
fetchUserData(true).catch((error) => {
|
||||||
|
console.error('💥 Initial fetch error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
console.log('Auth state change:', event); // Debug log
|
||||||
|
|
||||||
|
if (event === 'SIGNED_IN') {
|
||||||
|
// Background refresh without loading state
|
||||||
|
await fetchUserData(false);
|
||||||
|
} else if (event === 'SIGNED_OUT') {
|
||||||
|
setUser(null);
|
||||||
|
setProfile(null);
|
||||||
|
setAvatarUrl(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
} else if (event === 'TOKEN_REFRESHED') {
|
||||||
|
// Silent refresh - don't show loading
|
||||||
|
await fetchUserData(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [fetchUserData]);
|
||||||
|
|
||||||
|
const updateProfile = useCallback(
|
||||||
|
async (data: {
|
||||||
|
full_name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const result = await updateProfileAction(data);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to update profile');
|
||||||
|
}
|
||||||
|
setProfile(result.data);
|
||||||
|
|
||||||
|
// If avatar was updated, refresh the avatar URL
|
||||||
|
if (data.avatar_url && result.data.avatar_url) {
|
||||||
|
const avatarResponse = await getSignedUrl({
|
||||||
|
bucket: 'avatars',
|
||||||
|
url: result.data.avatar_url,
|
||||||
|
transform: { width: 128, height: 128 },
|
||||||
|
});
|
||||||
|
if (avatarResponse.success) {
|
||||||
|
setAvatarUrl(avatarResponse.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success('Profile updated successfully!');
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to update profile',
|
||||||
|
);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshUserData = useCallback(async () => {
|
||||||
|
await fetchUserData(true); // Manual refresh shows loading
|
||||||
|
}, [fetchUserData]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
avatarUrl,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
updateProfile,
|
||||||
|
refreshUserData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
68
src/components/context/Theme.tsx
Normal file
68
src/components/context/Theme.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
|
export const ThemeProvider = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) => {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ThemeToggleProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
|
||||||
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant='outline' size='icon' {...props}>
|
||||||
|
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
if (resolvedTheme === 'dark') setTheme('light');
|
||||||
|
else setTheme('dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
className='cursor-pointer'
|
||||||
|
onClick={toggleTheme}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Sun
|
||||||
|
style={{ height: `${size}rem`, width: `${size}rem` }}
|
||||||
|
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
|
||||||
|
/>
|
||||||
|
<Moon
|
||||||
|
style={{ height: `${size}rem`, width: `${size}rem` }}
|
||||||
|
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
|
||||||
|
/>
|
||||||
|
<span className='sr-only'>Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
@ -1,195 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React, {
|
|
||||||
type ReactNode,
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
|
||||||
getProfile,
|
|
||||||
getSignedUrl,
|
|
||||||
getUser,
|
|
||||||
updateProfile as updateProfileAction,
|
|
||||||
} from '@/lib/hooks';
|
|
||||||
import { type User, type Profile, createClient } from '@/utils/supabase';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
type AuthContextType = {
|
|
||||||
user: User | null;
|
|
||||||
profile: Profile | null;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
updateProfile: (data: {
|
|
||||||
full_name?: string;
|
|
||||||
email?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
|
|
||||||
refreshUserData: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
const fetchingRef = useRef(false);
|
|
||||||
|
|
||||||
const fetchUserData = useCallback(
|
|
||||||
async (showLoading = true) => {
|
|
||||||
if (fetchingRef.current) return;
|
|
||||||
fetchingRef.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Only show loading for initial load or manual refresh
|
|
||||||
if (showLoading) {
|
|
||||||
setIsLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userResponse = await getUser();
|
|
||||||
const profileResponse = await getProfile();
|
|
||||||
|
|
||||||
if (!userResponse.success || !profileResponse.success) {
|
|
||||||
setUser(null);
|
|
||||||
setProfile(null);
|
|
||||||
setAvatarUrl(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(userResponse.data);
|
|
||||||
setProfile(profileResponse.data);
|
|
||||||
|
|
||||||
// Get avatar URL if available
|
|
||||||
if (profileResponse.data.avatar_url) {
|
|
||||||
const avatarResponse = await getSignedUrl({
|
|
||||||
bucket: 'avatars',
|
|
||||||
url: profileResponse.data.avatar_url,
|
|
||||||
});
|
|
||||||
if (avatarResponse.success) {
|
|
||||||
setAvatarUrl(avatarResponse.data);
|
|
||||||
} else {
|
|
||||||
setAvatarUrl(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAvatarUrl(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
'Auth fetch error: ',
|
|
||||||
error instanceof Error
|
|
||||||
? `${error.message}`
|
|
||||||
: 'Failed to load user data!',
|
|
||||||
);
|
|
||||||
if (!isInitialized) {
|
|
||||||
toast.error('Failed to load user data!');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
setIsInitialized(true);
|
|
||||||
fetchingRef.current = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isInitialized],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const supabase = createClient();
|
|
||||||
|
|
||||||
// Initial fetch with loading
|
|
||||||
fetchUserData(true).catch((error) => {
|
|
||||||
console.error('💥 Initial fetch error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { subscription },
|
|
||||||
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
|
||||||
console.log('Auth state change:', event); // Debug log
|
|
||||||
|
|
||||||
if (event === 'SIGNED_IN') {
|
|
||||||
// Background refresh without loading state
|
|
||||||
await fetchUserData(false);
|
|
||||||
} else if (event === 'SIGNED_OUT') {
|
|
||||||
setUser(null);
|
|
||||||
setProfile(null);
|
|
||||||
setAvatarUrl(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
} else if (event === 'TOKEN_REFRESHED') {
|
|
||||||
// Silent refresh - don't show loading
|
|
||||||
await fetchUserData(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [fetchUserData]);
|
|
||||||
|
|
||||||
const updateProfile = useCallback(
|
|
||||||
async (data: {
|
|
||||||
full_name?: string;
|
|
||||||
email?: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const result = await updateProfileAction(data);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error ?? 'Failed to update profile');
|
|
||||||
}
|
|
||||||
setProfile(result.data);
|
|
||||||
|
|
||||||
// If avatar was updated, refresh the avatar URL
|
|
||||||
if (data.avatar_url && result.data.avatar_url) {
|
|
||||||
const avatarResponse = await getSignedUrl({
|
|
||||||
bucket: 'avatars',
|
|
||||||
url: result.data.avatar_url,
|
|
||||||
transform: { width: 128, height: 128 },
|
|
||||||
});
|
|
||||||
if (avatarResponse.success) {
|
|
||||||
setAvatarUrl(avatarResponse.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.success('Profile updated successfully!');
|
|
||||||
return { success: true, data: result.data };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating profile:', error);
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : 'Failed to update profile',
|
|
||||||
);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshUserData = useCallback(async () => {
|
|
||||||
await fetchUserData(true); // Manual refresh shows loading
|
|
||||||
}, [fetchUserData]);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
user,
|
|
||||||
profile,
|
|
||||||
avatarUrl,
|
|
||||||
isLoading,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
updateProfile,
|
|
||||||
refreshUserData,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
2
src/components/context/index.tsx
Normal file
2
src/components/context/index.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { AuthProvider, useAuth } from './Auth';
|
||||||
|
export { ThemeProvider, ThemeToggle } from './Theme';
|
@ -1,68 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import * as React from 'react';
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { Button } from '@/components/ui';
|
|
||||||
|
|
||||||
export const ThemeProvider = ({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) => {
|
|
||||||
const [mounted, setMounted] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ThemeToggleProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
|
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
|
||||||
const [mounted, setMounted] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<Button variant='outline' size='icon' {...props}>
|
|
||||||
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
if (resolvedTheme === 'dark') setTheme('light');
|
|
||||||
else setTheme('dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='icon'
|
|
||||||
className='cursor-pointer'
|
|
||||||
onClick={toggleTheme}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Sun
|
|
||||||
style={{ height: `${size}rem`, width: `${size}rem` }}
|
|
||||||
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
|
|
||||||
/>
|
|
||||||
<Moon
|
|
||||||
style={{ height: `${size}rem`, width: `${size}rem` }}
|
|
||||||
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
|
|
||||||
/>
|
|
||||||
<span className='sr-only'>Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { signInWithApple } from '@/lib/actions';
|
import { signInWithApple } from '@/lib/actions';
|
||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Button, type buttonVariants } from '@/components/ui';
|
import { type buttonVariants } from '@/components/ui';
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from 'react';
|
||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ export const SignInWithApple = ({
|
|||||||
width={22}
|
width={22}
|
||||||
height={22}
|
height={22}
|
||||||
/>
|
/>
|
||||||
<p className='text-[1.0rem]'>Sign in with Apple</p>
|
<p className='text-[1.0rem]'>Sign In with Apple</p>
|
||||||
</div>
|
</div>
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { signInWithMicrosoft } from '@/lib/actions';
|
import { signInWithMicrosoft } from '@/lib/actions';
|
||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Button, type buttonVariants } from '@/components/ui';
|
import { type buttonVariants } from '@/components/ui';
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from 'react';
|
||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ export const SignInWithMicrosoft = ({
|
|||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
/>
|
/>
|
||||||
<p className='text-[1.0rem]'>Sign in with Microsoft</p>
|
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
|
||||||
</div>
|
</div>
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { signOut } from '@/lib/actions';
|
import { signOut } from '@/lib/actions';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
import NavigationAuth from './auth';
|
import NavigationAuth from './auth';
|
||||||
import { ThemeToggle } from '@/components/context/theme';
|
import { ThemeToggle } from '@/components/context';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
const Navigation = () => {
|
const Navigation = () => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { SubmitButton } from '@/components/default';
|
import { SubmitButton } from '@/components/default';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
@ -122,18 +122,15 @@ export const ResetPasswordForm = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{statusMessage && (
|
{statusMessage &&
|
||||||
<div
|
(statusMessage.includes('Error') ||
|
||||||
className={`text-sm text-center ${
|
statusMessage.includes('error') ||
|
||||||
statusMessage.includes('Error') ||
|
statusMessage.includes('failed') ||
|
||||||
statusMessage.includes('failed')
|
statusMessage.includes('invalid') ? (
|
||||||
? 'text-destructive'
|
<StatusMessage message={{ error: statusMessage }} />
|
||||||
: 'text-green-600'
|
) : (
|
||||||
}`}
|
<StatusMessage message={{ message: statusMessage }} />
|
||||||
>
|
))}
|
||||||
{statusMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { CardHeader } from '@/components/ui';
|
import { CardHeader } from '@/components/ui';
|
||||||
import { SubmitButton } from '@/components/default';
|
import { SubmitButton } from '@/components/default';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { signOut } from '@/lib/actions';
|
import { signOut } from '@/lib/actions';
|
||||||
|
|
||||||
export const SignOut = () => {
|
export const SignOut = () => {
|
||||||
|
@ -78,7 +78,6 @@ export const signInWithApple = async (): Promise<Result<string>> => {
|
|||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'apple',
|
provider: 'apple',
|
||||||
options: {
|
options: {
|
||||||
scopes: 'openid, profile email offline_access',
|
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './public';
|
export * from './public';
|
||||||
//export * from './resizeImage';
|
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
export * from './useFileUpload';
|
export * from './useFileUpload';
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { replaceFile, uploadFile } from '@/lib/hooks';
|
import { replaceFile, uploadFile } from '@/lib/hooks';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAuth } from '@/components/context/auth';
|
import { useAuth } from '@/components/context';
|
||||||
import { resizeImage } from '@/lib/hooks';
|
import { resizeImage } from '@/lib/hooks';
|
||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
|
@ -1,7 +1,119 @@
|
|||||||
import { type NextRequest } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { updateSession } from '@/utils/supabase/middleware';
|
import { updateSession } from '@/utils/supabase/middleware';
|
||||||
|
|
||||||
|
// In-memory store for tracking IPs (use Redis in production)
|
||||||
|
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||||
|
const bannedIPs = new Set<string>();
|
||||||
|
|
||||||
|
// Suspicious patterns that indicate malicious activity
|
||||||
|
const MALICIOUS_PATTERNS = [
|
||||||
|
/web-inf/i,
|
||||||
|
/\.jsp/i,
|
||||||
|
/\.php/i,
|
||||||
|
/puttest/i,
|
||||||
|
/WEB-INF/i,
|
||||||
|
/\.xml$/i,
|
||||||
|
/perl/i,
|
||||||
|
/xampp/i,
|
||||||
|
/phpwebgallery/i,
|
||||||
|
/FileManager/i,
|
||||||
|
/standalonemanager/i,
|
||||||
|
/h2console/i,
|
||||||
|
/WebAdmin/i,
|
||||||
|
/login_form\.php/i,
|
||||||
|
/%2e/i,
|
||||||
|
/%u002e/i,
|
||||||
|
/\.%00/i,
|
||||||
|
/\.\./,
|
||||||
|
/lcgi/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Suspicious HTTP methods
|
||||||
|
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
|
||||||
|
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||||
|
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
|
||||||
|
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
const getClientIP = (request: NextRequest): string => {
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
|
const realIP = request.headers.get('x-real-ip');
|
||||||
|
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realIP) {
|
||||||
|
return realIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.ip ?? 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPathSuspicious = (pathname: string): boolean => {
|
||||||
|
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMethodSuspicious = (method: string): boolean => {
|
||||||
|
return SUSPICIOUS_METHODS.includes(method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIPAttempts = (ip: string): boolean => {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = ipAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
|
||||||
|
ipAttempts.set(ip, { count: 1, lastAttempt: now });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.count++;
|
||||||
|
attempts.lastAttempt = now;
|
||||||
|
|
||||||
|
if (attempts.count > MAX_ATTEMPTS) {
|
||||||
|
bannedIPs.add(ip);
|
||||||
|
// Clean up the attempts record
|
||||||
|
ipAttempts.delete(ip);
|
||||||
|
|
||||||
|
// Auto-unban after duration (in production, use a proper scheduler)
|
||||||
|
setTimeout(() => {
|
||||||
|
bannedIPs.delete(ip);
|
||||||
|
}, BAN_DURATION);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
export const middleware = async (request: NextRequest) => {
|
export const middleware = async (request: NextRequest) => {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const method = request.method;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
// Check if IP is already banned
|
||||||
|
if (bannedIPs.has(ip)) {
|
||||||
|
console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
|
||||||
|
return new NextResponse('Access denied.', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspicious activity
|
||||||
|
const isSuspiciousPath = isPathSuspicious(pathname);
|
||||||
|
const isSuspiciousMethod = isMethodSuspicious(method);
|
||||||
|
|
||||||
|
if (isSuspiciousPath || isSuspiciousMethod) {
|
||||||
|
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
|
||||||
|
|
||||||
|
const shouldBan = updateIPAttempts(ip);
|
||||||
|
|
||||||
|
if (shouldBan) {
|
||||||
|
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
|
||||||
|
return new NextResponse('Access denied - IP banned', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 404 to not reveal the blocking mechanism
|
||||||
|
return new NextResponse('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
return await updateSession(request);
|
return await updateSession(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user