Get started on turning this to tech tracker
10
package.json
@ -24,7 +24,7 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@sentry/nextjs": "^9.27.0",
|
||||
"@sentry/nextjs": "^9.28.0",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.50.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
@ -38,7 +38,7 @@
|
||||
"react-hook-form": "^7.57.0",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"sonner": "^2.0.5",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.58"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
@ -46,7 +46,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react": "^19.1.7",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
@ -56,12 +56,12 @@
|
||||
"postcss": "^8.5.4",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.33.1"
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
|
850
pnpm-lock.yaml
generated
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
@ -17,9 +17,7 @@ export const GET = async (request: NextRequest) => {
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (error) {
|
||||
console.error('OAuth error:', error);
|
||||
return redirect(
|
||||
`/sign-in?error=${encodeURIComponent(error.message)}`,
|
||||
);
|
||||
return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
return redirect(redirectTo);
|
||||
}
|
||||
|
@ -21,9 +21,7 @@ const AuthSuccessPage = () => {
|
||||
};
|
||||
|
||||
handleAuthSuccess().catch((error) => {
|
||||
console.error(
|
||||
`Error: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
||||
});
|
||||
}, [refreshUserData, router]);
|
||||
|
||||
|
@ -57,8 +57,7 @@ const ForgotPassword = () => {
|
||||
if (result?.success) {
|
||||
await refreshUserData();
|
||||
setStatusMessage(
|
||||
result?.data ??
|
||||
'Check your email for a link to reset your password.',
|
||||
result?.data ?? 'Check your email for a link to reset your password.',
|
||||
);
|
||||
form.reset();
|
||||
router.push('');
|
||||
@ -75,9 +74,7 @@ const ForgotPassword = () => {
|
||||
return (
|
||||
<Card className='min-w-xs md:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl font-medium'>
|
||||
Reset Password
|
||||
</CardTitle>
|
||||
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
|
||||
<CardDescription className='text-sm text-foreground'>
|
||||
Don't have an account?{' '}
|
||||
<Link className='font-medium underline' href='/sign-up'>
|
||||
@ -119,13 +116,9 @@ const ForgotPassword = () => {
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage
|
||||
message={{ error: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage
|
||||
message={{ success: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ success: statusMessage }} />
|
||||
))}
|
||||
</form>
|
||||
</Form>
|
||||
|
@ -97,8 +97,7 @@ const ProfilePage = () => {
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-2xl'>Your Profile</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your personal information and how it appears to
|
||||
others
|
||||
Manage your personal information and how it appears to others
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{isLoading && !profile ? (
|
||||
@ -111,9 +110,7 @@ const ProfilePage = () => {
|
||||
<Separator />
|
||||
<ProfileForm onSubmit={handleProfileSubmit} />
|
||||
<Separator />
|
||||
<ResetPasswordForm
|
||||
onSubmit={handleResetPasswordSubmit}
|
||||
/>
|
||||
<ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
|
||||
<Separator />
|
||||
<SignOut />
|
||||
</div>
|
||||
|
@ -99,9 +99,7 @@ const Login = () => {
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormLabel className='text-lg'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
@ -120,9 +118,7 @@ const Login = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-lg'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<FormLabel className='text-lg'>Password</FormLabel>
|
||||
<Link
|
||||
className='text-xs text-foreground underline text-right'
|
||||
href='/forgot-password'
|
||||
@ -146,13 +142,9 @@ const Login = () => {
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage
|
||||
message={{ error: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage
|
||||
message={{ message: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
|
@ -104,10 +104,7 @@ const SignUp = () => {
|
||||
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
|
||||
<CardDescription className='text-foreground'>
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
className='text-primary font-medium underline'
|
||||
href='/sign-in'
|
||||
>
|
||||
<Link className='text-primary font-medium underline' href='/sign-in'>
|
||||
Sign in
|
||||
</Link>
|
||||
</CardDescription>
|
||||
@ -123,15 +120,9 @@ const SignUp = () => {
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormLabel className='text-lg'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Full Name'
|
||||
{...field}
|
||||
/>
|
||||
<Input type='text' placeholder='Full Name' {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -141,9 +132,7 @@ const SignUp = () => {
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormLabel className='text-lg'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
@ -160,9 +149,7 @@ const SignUp = () => {
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<FormLabel className='text-lg'>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
@ -179,9 +166,7 @@ const SignUp = () => {
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-lg'>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
<FormLabel className='text-lg'>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
@ -198,13 +183,9 @@ const SignUp = () => {
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage
|
||||
message={{ error: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage
|
||||
message={{ success: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ success: statusMessage }} />
|
||||
))}
|
||||
<SubmitButton
|
||||
className='text-[1.0rem] cursor-pointer'
|
||||
|
@ -27,15 +27,9 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang='en'
|
||||
className={`${geist.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
'bg-background text-foreground font-sans antialiased',
|
||||
)}
|
||||
className={cn('bg-background text-foreground font-sans antialiased')}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
@ -53,9 +47,7 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => reset()}>Try again</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,13 +15,14 @@ import * as Sentry from '@sentry/nextjs';
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: {
|
||||
template: '%s | T3 Template',
|
||||
default: 'T3 Template with Supabase',
|
||||
template: '%s | Tech Tracker',
|
||||
default: 'Tech Tracker',
|
||||
},
|
||||
description: 'Created by Gib with T3!',
|
||||
applicationName: 'T3 Template',
|
||||
description: 'Created by Gib with Next.js & Supabase!',
|
||||
applicationName: 'Tech Tracker',
|
||||
keywords:
|
||||
'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
|
||||
'Tech Tracker, City of Gulfport, Information Technology, T3 Template, ' +
|
||||
'Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib',
|
||||
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
|
||||
creator: 'Gib Brown',
|
||||
publisher: 'Gib Brown',
|
||||
@ -341,71 +342,71 @@ export const generateMetadata = (): Metadata => {
|
||||
other: {
|
||||
...Sentry.getTraceData(),
|
||||
},
|
||||
twitter: {
|
||||
card: 'app',
|
||||
title: 'T3 Template',
|
||||
description: 'Created by Gib with T3!',
|
||||
siteId: '',
|
||||
creator: '@cs_gib',
|
||||
creatorId: '',
|
||||
images: {
|
||||
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
|
||||
alt: 'T3 Template',
|
||||
},
|
||||
app: {
|
||||
name: 'T3 Template',
|
||||
id: {
|
||||
iphone: '',
|
||||
ipad: '',
|
||||
googleplay: '',
|
||||
},
|
||||
url: {
|
||||
iphone: '',
|
||||
ipad: '',
|
||||
googleplay: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
google: 'google',
|
||||
yandex: 'yandex',
|
||||
yahoo: 'yahoo',
|
||||
},
|
||||
itunes: {
|
||||
appId: '',
|
||||
appArgument: '',
|
||||
},
|
||||
appleWebApp: {
|
||||
title: 'T3 Template',
|
||||
statusBarStyle: 'black-translucent',
|
||||
startupImage: [
|
||||
'/icons/apple/splash-768x1004.png',
|
||||
{
|
||||
url: '/icons/apple/splash-1536x2008.png',
|
||||
media: '(device-width: 768px) and (device-height: 1024px)',
|
||||
},
|
||||
],
|
||||
},
|
||||
appLinks: {
|
||||
ios: {
|
||||
url: 'https://t3-template.gbrown.org/ios',
|
||||
app_store_id: 't3_template',
|
||||
},
|
||||
android: {
|
||||
package: 'org.gbrown.android/t3-template',
|
||||
app_name: 'app_t3_template',
|
||||
},
|
||||
web: {
|
||||
url: 'https://t3-template.gbrown.org/web',
|
||||
should_fallback: true,
|
||||
},
|
||||
},
|
||||
facebook: {
|
||||
appId: '',
|
||||
},
|
||||
pinterest: {
|
||||
richPin: true,
|
||||
},
|
||||
//twitter: {
|
||||
//card: 'app',
|
||||
//title: 'Tech Tracker',
|
||||
//description: 'Created by Gib with Next.js & Supabase!',
|
||||
//siteId: '',
|
||||
//creator: '@cs_gib',
|
||||
//creatorId: '',
|
||||
//images: {
|
||||
//url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
|
||||
//alt: 'Tech Tracker',
|
||||
//},
|
||||
//app: {
|
||||
//name: 'Tech Tracker',
|
||||
//id: {
|
||||
//iphone: '',
|
||||
//ipad: '',
|
||||
//googleplay: '',
|
||||
//},
|
||||
//url: {
|
||||
//iphone: '',
|
||||
//ipad: '',
|
||||
//googleplay: '',
|
||||
//},
|
||||
//},
|
||||
//},
|
||||
//verification: {
|
||||
//google: 'google',
|
||||
//yandex: 'yandex',
|
||||
//yahoo: 'yahoo',
|
||||
//},
|
||||
//itunes: {
|
||||
//appId: '',
|
||||
//appArgument: '',
|
||||
//},
|
||||
//appleWebApp: {
|
||||
//title: 'Tech Tracker',
|
||||
//statusBarStyle: 'black-translucent',
|
||||
//startupImage: [
|
||||
//'/icons/apple/splash-768x1004.png',
|
||||
//{
|
||||
//url: '/icons/apple/splash-1536x2008.png',
|
||||
//media: '(device-width: 768px) and (device-height: 1024px)',
|
||||
//},
|
||||
//],
|
||||
//},
|
||||
//appLinks: {
|
||||
//ios: {
|
||||
//url: 'https://techtracker.gbrown.org/ios',
|
||||
//app_store_id: 'com.gbrown.techtracker',
|
||||
//},
|
||||
//android: {
|
||||
//package: 'https://techtracker.gbrown.org/android',
|
||||
//app_name: 'app_t3_template',
|
||||
//},
|
||||
//web: {
|
||||
//url: 'https://techtracker.gbrown.org',
|
||||
//should_fallback: true,
|
||||
//},
|
||||
//},
|
||||
//facebook: {
|
||||
//appId: '',
|
||||
//},
|
||||
//pinterest: {
|
||||
//richPin: true,
|
||||
//},
|
||||
category: 'technology',
|
||||
};
|
||||
};
|
||||
@ -417,15 +418,9 @@ const geist = Geist({
|
||||
|
||||
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<html
|
||||
lang='en'
|
||||
className={`${geist.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
'bg-background text-foreground font-sans antialiased',
|
||||
)}
|
||||
className={cn('bg-background text-foreground font-sans antialiased')}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
|
@ -31,9 +31,9 @@ const HomePage = async () => {
|
||||
Welcome to the T3 Supabase Template!
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[1.0rem] mb-2'>
|
||||
A great place to start is by creating a new user
|
||||
account & ensuring you can sign up! If you
|
||||
already have an account, go ahead and sign in!
|
||||
A great place to start is by creating a new user account &
|
||||
ensuring you can sign up! If you already have an account, go
|
||||
ahead and sign in!
|
||||
</CardDescription>
|
||||
<SignInSignUp
|
||||
className='flex gap-4 w-full justify-center'
|
||||
@ -42,9 +42,7 @@ const HomePage = async () => {
|
||||
/>
|
||||
<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>
|
||||
<span className='text-sm text-muted-foreground'>or</span>
|
||||
<Separator className='flex-1 bg-accent py-0.5' />
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
@ -55,8 +53,8 @@ const HomePage = async () => {
|
||||
<Separator className='bg-accent' />
|
||||
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
|
||||
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
|
||||
You can also test out your connection to Sentry
|
||||
if you want to start there!
|
||||
You can also test out your connection to Sentry if you want to
|
||||
start there!
|
||||
</CardTitle>
|
||||
<TestSentryCard />
|
||||
</CardContent>
|
||||
|
@ -161,9 +161,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to update profile',
|
||||
error instanceof Error ? error.message : 'Failed to update profile',
|
||||
);
|
||||
return { success: false, error };
|
||||
}
|
||||
@ -185,9 +183,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
refreshUserData,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
'use server';
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Button, type buttonVariants } from '@/components/ui';
|
||||
@ -13,7 +13,7 @@ type SignInSignUpProps = {
|
||||
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
|
||||
};
|
||||
|
||||
export const SignInSignUp = async ({
|
||||
export const SignInSignUp = ({
|
||||
className = 'flex gap-2',
|
||||
signInSize = 'default',
|
||||
signUpSize = 'sm',
|
||||
|
@ -71,9 +71,7 @@ export const SignInWithApple = ({
|
||||
<p className='text-[1.0rem]'>Sign In with Apple</p>
|
||||
</div>
|
||||
</SubmitButton>
|
||||
{statusMessage && (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
)}
|
||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
@ -64,9 +64,7 @@ export const SignInWithMicrosoft = ({
|
||||
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
|
||||
</div>
|
||||
</SubmitButton>
|
||||
{statusMessage && (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
)}
|
||||
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
@ -1,22 +1,17 @@
|
||||
'use server';
|
||||
'use client';
|
||||
|
||||
import { getProfile } from '@/lib/actions';
|
||||
import AvatarDropdown from './AvatarDropdown';
|
||||
import { SignInSignUp } from '@/components/default/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
|
||||
const NavigationAuth = async () => {
|
||||
try {
|
||||
const profile = await getProfile();
|
||||
return profile.success ? (
|
||||
const NavigationAuth = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return isAuthenticated ? (
|
||||
<div className='flex items-center gap-4'>
|
||||
<AvatarDropdown />
|
||||
</div>
|
||||
) : (
|
||||
<SignInSignUp />
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error getting profile: ${error as string}`);
|
||||
return <SignInSignUp />;
|
||||
}
|
||||
};
|
||||
export default NavigationAuth;
|
||||
|
@ -1,12 +1,21 @@
|
||||
'use server';
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui';
|
||||
import NavigationAuth from './auth';
|
||||
import { ThemeToggle, TVToggle } from '@/components/context';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import NavigationAuth from './auth';
|
||||
import { ThemeToggle, TVToggle, useTVMode } from '@/components/context';
|
||||
|
||||
const Navigation = () => {
|
||||
const { tvMode } = useTVMode();
|
||||
if (tvMode) {
|
||||
return (
|
||||
<div className='absolute top-4 right-2'>
|
||||
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
|
||||
<TVToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<nav
|
||||
className='w-full flex justify-center
|
||||
@ -18,23 +27,9 @@ const Navigation = () => {
|
||||
>
|
||||
<div className='flex gap-5 items-center font-semibold'>
|
||||
<Link className='flex flex-row my-auto gap-2' href='/'>
|
||||
<Image
|
||||
src='/favicon.png'
|
||||
alt='T3 Logo'
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<h1 className='my-auto text-2xl'>
|
||||
T3 Supabase Template
|
||||
</h1>
|
||||
<Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
|
||||
<h1 className='my-auto text-2xl'>Tech Tracker</h1>
|
||||
</Link>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild>
|
||||
<Link href='https://git.gbrown.org/gib/T3-Template'>
|
||||
Go to Git Repo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<TVToggle />
|
||||
|
@ -55,10 +55,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||
return (
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='full_name'
|
||||
@ -68,9 +65,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your public display name.
|
||||
</FormDescription>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@ -86,8 +81,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your
|
||||
account.
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -95,10 +89,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||
/>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Saving...'
|
||||
>
|
||||
<SubmitButton disabled={isLoading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
|
@ -69,9 +69,7 @@ export const ResetPasswordForm = ({
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Password was not updated!',
|
||||
error instanceof Error ? error.message : 'Password was not updated!',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -102,8 +100,7 @@ export const ResetPasswordForm = ({
|
||||
<Input type='password' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your new password. Must be at
|
||||
least 8 characters.
|
||||
Enter your new password. Must be at least 8 characters.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -119,8 +116,7 @@ export const ResetPasswordForm = ({
|
||||
<Input type='password' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please re-enter your new password to
|
||||
confirm.
|
||||
Please re-enter your new password to confirm.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -131,13 +127,9 @@ export const ResetPasswordForm = ({
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage
|
||||
message={{ error: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage
|
||||
message={{ message: statusMessage }}
|
||||
/>
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton
|
||||
|
@ -69,9 +69,7 @@ export const TestSentryCard = () => {
|
||||
fill='currentcolor'
|
||||
/>
|
||||
</svg>
|
||||
<CardTitle className='text-3xl my-auto'>
|
||||
Test Sentry
|
||||
</CardTitle>
|
||||
<CardTitle className='text-3xl my-auto'>Test Sentry</CardTitle>
|
||||
</div>
|
||||
<CardDescription className='text-[1.0rem]'>
|
||||
Click the button below & view the sample error on{' '}
|
||||
@ -81,8 +79,8 @@ export const TestSentryCard = () => {
|
||||
>
|
||||
the Sentry website
|
||||
</Link>
|
||||
. Navigate to the {"'"}Issues{"'"} page & you should see the
|
||||
sample error!
|
||||
. Navigate to the {"'"}Issues{"'"} page & you should see the sample
|
||||
error!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -97,20 +95,14 @@ export const TestSentryCard = () => {
|
||||
{hasSentError ? (
|
||||
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
|
||||
<CheckCircle size={30} className='my-auto' />
|
||||
<p className='text-lg'>
|
||||
Sample error was sent to Sentry!
|
||||
</p>
|
||||
<p className='text-lg'>Sample error was sent to Sentry!</p>
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
|
||||
<MessageCircleWarning
|
||||
size={40}
|
||||
className='my-auto'
|
||||
/>
|
||||
<MessageCircleWarning size={40} className='my-auto' />
|
||||
<p>
|
||||
Wait! The Sentry SDK is not able to reach Sentry
|
||||
right now - this may be due to an adblocker. For
|
||||
more information, see{' '}
|
||||
Wait! The Sentry SDK is not able to reach Sentry right now -
|
||||
this may be due to an adblocker. For more information, see{' '}
|
||||
<Link
|
||||
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data'
|
||||
className='text-accent-foreground underline hover:text-primary'
|
||||
@ -125,8 +117,8 @@ export const TestSentryCard = () => {
|
||||
</div>
|
||||
<Separator className='my-4 bg-accent' />
|
||||
<p className='description'>
|
||||
Warning! Sometimes Adblockers will prevent errors from being
|
||||
sent to Sentry.
|
||||
Warning! Sometimes Adblockers will prevent errors from being sent to
|
||||
Sentry.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -57,9 +57,9 @@ export const FetchDataSteps = () => {
|
||||
>
|
||||
Table Editor
|
||||
</a>{' '}
|
||||
for your Supabase project to create a table and insert some
|
||||
example data. If you're stuck for creativity, you can
|
||||
copy and paste the following into the{' '}
|
||||
for your Supabase project to create a table and insert some example
|
||||
data. If you're stuck for creativity, you can copy and paste the
|
||||
following into the{' '}
|
||||
<a
|
||||
href='https://supabase.com/dashboard/project/_/sql/new'
|
||||
className='font-bold hover:underline text-foreground/80'
|
||||
@ -75,8 +75,8 @@ export const FetchDataSteps = () => {
|
||||
|
||||
<TutorialStep title='Query Supabase data from Next.js'>
|
||||
<p>
|
||||
To create a Supabase client and query data from an Async
|
||||
Server Component, create a new page.tsx file at{' '}
|
||||
To create a Supabase client and query data from an Async Server
|
||||
Component, create a new page.tsx file at{' '}
|
||||
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
|
||||
/app/notes/page.tsx
|
||||
</span>{' '}
|
||||
|
@ -17,7 +17,8 @@ const buttonVariants = cva(
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
|
@ -16,10 +16,7 @@ function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal
|
||||
data-slot='dropdown-menu-portal'
|
||||
{...props}
|
||||
/>
|
||||
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -58,10 +55,7 @@ function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group
|
||||
data-slot='dropdown-menu-group'
|
||||
{...props}
|
||||
/>
|
||||
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -201,9 +195,7 @@ function DropdownMenuShortcut({
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
|
||||
);
|
||||
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
|
@ -40,8 +40,7 @@ export const env = createEnv({
|
||||
CI: process.env.CI,
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY:
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||
|
@ -124,9 +124,7 @@ export const uploadFile = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error uploading file',
|
||||
error instanceof Error ? error.message : 'Unknown error uploading file',
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -149,9 +147,7 @@ export const replaceFile = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error replacing file',
|
||||
error instanceof Error ? error.message : 'Unknown error replacing file',
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -175,9 +171,7 @@ export const deleteFile = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error deleting file',
|
||||
error instanceof Error ? error.message : 'Unknown error deleting file',
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -211,9 +205,7 @@ export const listFiles = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error listing files',
|
||||
error instanceof Error ? error.message : 'Unknown error listing files',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -124,9 +124,7 @@ export const uploadFile = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error uploading file',
|
||||
error instanceof Error ? error.message : 'Unknown error uploading file',
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -152,9 +150,7 @@ export const replaceFile = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error replacing file',
|
||||
error instanceof Error ? error.message : 'Unknown error replacing file',
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -178,9 +174,7 @@ export const deleteFile = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error deleting file',
|
||||
error instanceof Error ? error.message : 'Unknown error deleting file',
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -214,9 +208,7 @@ export const listFiles = async ({
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error listing files',
|
||||
error instanceof Error ? error.message : 'Unknown error listing files',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -72,9 +72,7 @@ export const useFileUpload = () => {
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
throw new Error(
|
||||
uploadResult.error || `Failed to upload to ${bucket}`,
|
||||
);
|
||||
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
|
||||
}
|
||||
|
||||
return { success: true, data: uploadResult.data };
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -41,10 +41,7 @@ export const updateSession = async (
|
||||
const user = await supabase.auth.getUser();
|
||||
|
||||
// protected routes
|
||||
if (
|
||||
request.nextUrl.pathname.startsWith('/reset-password') &&
|
||||
user.error
|
||||
) {
|
||||
if (request.nextUrl.pathname.startsWith('/reset-password') && user.error) {
|
||||
return NextResponse.redirect(new URL('/sign-in', request.url));
|
||||
}
|
||||
|
||||
|