Compare commits

...

6 Commits

78 changed files with 5543 additions and 7368 deletions

View File

@@ -9,7 +9,6 @@
# Next Variables # Default Values:
#NODE_ENV= # development
#SKIP_ENV_VALIDATION= # false
#NEXT_RUNTIME= # nodejs
# Sentry Variables # Default Values:
SENTRY_AUTH_TOKEN=
#CI= # true

View File

@@ -1,5 +1,6 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"useTabs": true
}

View File

@@ -1,5 +1,6 @@
import { FlatCompat } from '@eslint/eslintrc';
import tseslint from 'typescript-eslint';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
@@ -16,6 +17,7 @@ export default tseslint.config(
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
eslintPluginPrettierRecommended,
],
rules: {
'@typescript-eslint/array-type': 'off',
@@ -33,6 +35,9 @@ export default tseslint.config(
'error',
{ checksVoidReturn: { attributes: false } },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
},
},
{

View File

@@ -1,6 +1,5 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
/* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
@@ -22,14 +21,21 @@ const config = {
bodySizeLimit: '10mb',
},
},
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
turbopack: {
rules: {
'*.svg': {
loaders: [
{
loader: '@svgr/webpack',
options: {
icon: true,
},
},
],
as: '*.js',
},
},
},
};
const sentryConfig = {

View File

@@ -6,8 +6,8 @@
"scripts": {
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "next dev",
"dev:turbo": "next dev --turbo",
"dev": "next dev --turbo",
"dev:slow": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
@@ -50,6 +50,9 @@
"@types/react-dom": "^19.1.6",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"import-in-the-middle": "^1.14.0",
"postcss": "^8.5.4",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12",
@@ -63,5 +66,5 @@
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912"
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
}

3559
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://0468176d5291bc2b914261147bfef117@sentry.gbrown.org/6',
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@@ -1,12 +1,12 @@
'use client';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Loader2 } from 'lucide-react';
const AuthSuccessPage = () => {
const { refreshUserData, isAuthenticated } = useAuth();
const { refreshUserData } = useAuth();
const router = useRouter();
useEffect(() => {

View File

@@ -19,7 +19,7 @@ import {
import Link from 'next/link';
import { forgotPassword } from '@/lib/actions';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';

View File

@@ -1,5 +1,5 @@
'use client';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import {

View File

@@ -20,7 +20,7 @@ import {
import Link from 'next/link';
import { signIn } from '@/lib/actions';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui';
@@ -155,7 +155,12 @@ const Login = () => {
</SubmitButton>
</form>
</Form>
<Separator className='my-4' />
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent>

View File

@@ -7,7 +7,7 @@ import Link from 'next/link';
import { signUp } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import {
Card,
CardContent,
@@ -21,8 +21,13 @@ import {
FormLabel,
FormMessage,
Input,
Separator,
} from '@/components/ui';
import { useEffect, useState } from 'react';
import {
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const formSchema = z
.object({
@@ -108,7 +113,7 @@ const SignUp = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignUp)}
className='flex flex-col mx-auto space-y-4'
className='flex flex-col mx-auto space-y-4 mb-4'
>
<FormField
control={form.control}
@@ -191,6 +196,13 @@ const SignUp = () => {
</SubmitButton>
</form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft type='signUp' />
<SignInWithApple type='signUp' />
</CardContent>
</Card>
);

View File

@@ -2,8 +2,7 @@
import '@/styles/globals.css';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme';
import { AuthProvider } from '@/components/context/auth';
import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui';

View File

@@ -2,8 +2,7 @@ import type { Metadata } from 'next';
import '@/styles/globals.css';
import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme';
import { AuthProvider } from '@/components/context/auth';
import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui';

View File

@@ -4,10 +4,8 @@ import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase';
import Link from 'next/link';
import { TestSentryCard } from '@/components/default/sentry';
import {
Button,
Card,
CardContent,
CardDescription,
@@ -15,6 +13,11 @@ import {
CardTitle,
Separator,
} from '@/components/ui';
import {
SignInSignUp,
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const HomePage = async () => {
const response = await getUser();
@@ -29,16 +32,23 @@ const HomePage = async () => {
</CardTitle>
<CardDescription className='text-[1.0rem] mb-2'>
A great place to start is by creating a new user account &
ensuring you can sign in!
ensuring you can sign up! If you already have an account, go
ahead and sign in!
</CardDescription>
<Button
asChild
size='sm'
variant={'default'}
className='w-1/3 items-center'
>
<Link href='/sign-up'>Sign up</Link>
</Button>
<SignInSignUp
className='flex gap-4 w-full justify-center'
signInSize='xl'
signUpSize='xl'
/>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<div className='flex gap-4'>
<SignInWithMicrosoft buttonSize='lg' />
<SignInWithApple buttonSize='lg' />
</div>
</CardHeader>
<Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>

View 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;
};

View 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>
);
};

View File

@@ -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;
};

View File

@@ -0,0 +1,2 @@
export { AuthProvider, useAuth } from './Auth';
export { ThemeProvider, ThemeToggle } from './Theme';

View File

@@ -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>
);
};

View File

@@ -0,0 +1,33 @@
'use server';
import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInSignUpProps = {
className?: ComponentProps<'div'>['className'];
signInSize?: VariantProps<typeof buttonVariants>['size'];
signUpSize?: VariantProps<typeof buttonVariants>['size'];
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInSignUp = async ({
className = 'flex gap-2',
signInSize = 'default',
signUpSize = 'sm',
signInVariant = 'outline',
signUpVariant = 'default',
}: SignInSignUpProps) => {
return (
<div className={className}>
<Button asChild size={signInSize} variant={signInVariant}>
<Link href='/sign-in'>Sign In</Link>
</Button>
<Button asChild size={signUpSize} variant={signUpVariant}>
<Link href='/sign-up'>Sign Up</Link>
</Button>
</div>
);
};

View File

@@ -1,12 +1,25 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
export const SignInWithApple = () => {
type SignInWithAppleProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithApple = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithAppleProps) => {
const router = useRouter();
const { isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
@@ -38,8 +51,10 @@ export const SignInWithApple = () => {
};
return (
<form onSubmit={handleSignInWithApple} className='my-4'>
<form onSubmit={handleSignInWithApple} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
@@ -53,7 +68,7 @@ export const SignInWithApple = () => {
width={22}
height={22}
/>
<p className='text-[1.0rem]'>Sign in with Apple</p>
<p className='text-[1.0rem]'>Sign In with Apple</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}

View File

@@ -1,11 +1,24 @@
'use client';
import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
export const SignInWithMicrosoft = () => {
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithMicrosoft = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithMicrosoftProps) => {
const { isLoading } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
@@ -32,8 +45,10 @@ export const SignInWithMicrosoft = () => {
};
return (
<form onSubmit={handleSignInWithMicrosoft} className='my-4'>
<form onSubmit={handleSignInWithMicrosoft} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
@@ -46,7 +61,7 @@ export const SignInWithMicrosoft = () => {
width={20}
height={20}
/>
<p className='text-[1.0rem]'>Sign in with Microsoft</p>
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}

View File

@@ -0,0 +1,3 @@
export * from './SignInSignUp';
export * from './SignInWithApple';
export * from './SignInWithMicrosoft';

View File

@@ -12,7 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/actions';
import { User } from 'lucide-react';
@@ -77,7 +77,7 @@ const AvatarDropdown = () => {
onClick={handleSignOut}
className='w-full justify-center cursor-pointer'
>
Log out
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,9 +1,8 @@
'use server';
import Link from 'next/link';
import { Button } from '@/components/ui';
import { getProfile } from '@/lib/actions';
import AvatarDropdown from './AvatarDropdown';
import { SignInSignUp } from '@/components/default/auth';
const NavigationAuth = async () => {
try {
@@ -13,17 +12,11 @@ const NavigationAuth = async () => {
<AvatarDropdown />
</div>
) : (
<div className='flex gap-2'>
<Button asChild size='default' variant={'outline'}>
<Link href='/sign-in'>Sign in</Link>
</Button>
<Button asChild size='sm' variant={'default'}>
<Link href='/sign-up'>Sign up</Link>
</Button>
</div>
<SignInSignUp />
);
} catch (error) {
console.error('Error getting profile:', error);
console.error(`Error getting profile: ${error as string}`);
return <SignInSignUp />;
}
};
export default NavigationAuth;

View File

@@ -3,7 +3,7 @@
import Link from 'next/link';
import { Button } from '@/components/ui';
import NavigationAuth from './auth';
import { ThemeToggle } from '@/components/context/theme';
import { ThemeToggle } from '@/components/context';
import Image from 'next/image';
const Navigation = () => {

View File

@@ -1,5 +1,5 @@
import { useFileUpload } from '@/lib/hooks/useFileUpload';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import {
Avatar,
AvatarFallback,

View File

@@ -13,7 +13,7 @@ import {
Input,
} from '@/components/ui';
import { useEffect } from 'react';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { SubmitButton } from '@/components/default';
const formSchema = z.object({

View File

@@ -122,18 +122,15 @@ export const ResetPasswordForm = ({
</FormItem>
)}
/>
{statusMessage && (
<div
className={`text-sm text-center ${
statusMessage.includes('Error') ||
statusMessage.includes('failed')
? 'text-destructive'
: 'text-green-600'
}`}
>
{statusMessage}
</div>
)}
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ message: statusMessage }} />
))}
<div className='flex justify-center'>
<SubmitButton
disabled={isLoading}

View File

@@ -3,7 +3,7 @@
import { CardHeader } from '@/components/ui';
import { SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { signOut } from '@/lib/actions';
export const SignOut = () => {

View File

@@ -27,6 +27,7 @@ export const TestSentryCard = () => {
useEffect(() => {
const checkConnectivity = async () => {
console.log('Checking Sentry SDK connectivity...');
const result = await Sentry.diagnoseSdkConnectivity();
setIsConnected(result !== 'sentry-unreachable');
};

View File

@@ -25,6 +25,8 @@ const buttonVariants = cva(
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
xl: 'h-12 rounded-md px-8 has-[>svg]:px-6',
xxl: 'h-14 rounded-md px-10 has-[>svg]:px-8',
icon: 'size-9',
smicon: 'size-6',
},

View File

@@ -10,7 +10,6 @@ export const env = createEnv({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
NEXT_RUNTIME: z.enum(['nodejs', 'edge']).default('nodejs'),
SENTRY_AUTH_TOKEN: z.string().min(1),
CI: z.enum(['true', 'false']).default('false'),
},
@@ -37,7 +36,6 @@ export const env = createEnv({
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_RUNTIME: process.env.NEXT_RUNTIME,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,

View File

@@ -2,9 +2,7 @@ import * as Sentry from '@sentry/nextjs';
import type { Instrumentation } from 'next';
export const register = async () => {
if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config');
} else await import('../sentry.server.config');
await import('../sentry.server.config');
};
export const onRequestError: Instrumentation.onRequestError = (...args) => {

View File

@@ -78,7 +78,6 @@ export const signInWithApple = async (): Promise<Result<string>> => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'openid, profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});

View File

@@ -1,6 +1,5 @@
export * from './auth';
export * from './public';
//export * from './resizeImage';
export * from './storage';
export * from './useFileUpload';

View File

@@ -3,7 +3,7 @@
import { useState, useRef } from 'react';
import { replaceFile, uploadFile } from '@/lib/hooks';
import { toast } from 'sonner';
import { useAuth } from '@/components/context/auth';
import { useAuth } from '@/components/context';
import { resizeImage } from '@/lib/hooks';
import type { Result } from '.';

View File

@@ -1,7 +1,119 @@
import { type NextRequest } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
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) => {
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);
};