Get started on turning this to tech tracker

This commit is contained in:
2025-06-11 11:18:57 -05:00
parent c2e816591d
commit 6c06dbc535
47 changed files with 717 additions and 733 deletions

View File

@ -24,7 +24,7 @@
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^9.27.0", "@sentry/nextjs": "^9.28.0",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0", "@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
@ -38,7 +38,7 @@
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"zod": "^3.25.56" "zod": "^3.25.58"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
@ -46,7 +46,7 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^20.19.0", "@types/node": "^20.19.0",
"@types/react": "^19.1.6", "@types/react": "^19.1.7",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-next": "^15.3.3", "eslint-config-next": "^15.3.3",
@ -56,12 +56,12 @@
"postcss": "^8.5.4", "postcss": "^8.5.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12", "prettier-plugin-tailwindcss": "^0.6.12",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.33.1" "typescript-eslint": "^8.34.0"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"

850
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -17,9 +17,7 @@ export const GET = async (request: NextRequest) => {
const { error } = await supabase.auth.exchangeCodeForSession(code); const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) { if (error) {
console.error('OAuth error:', error); console.error('OAuth error:', error);
return redirect( return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
`/sign-in?error=${encodeURIComponent(error.message)}`,
);
} }
return redirect(redirectTo); return redirect(redirectTo);
} }

View File

@ -21,9 +21,7 @@ const AuthSuccessPage = () => {
}; };
handleAuthSuccess().catch((error) => { handleAuthSuccess().catch((error) => {
console.error( console.error(`Error: ${error instanceof Error ? error.message : error}`);
`Error: ${error instanceof Error ? error.message : error}`,
);
}); });
}, [refreshUserData, router]); }, [refreshUserData, router]);

View File

@ -57,8 +57,7 @@ const ForgotPassword = () => {
if (result?.success) { if (result?.success) {
await refreshUserData(); await refreshUserData();
setStatusMessage( setStatusMessage(
result?.data ?? result?.data ?? 'Check your email for a link to reset your password.',
'Check your email for a link to reset your password.',
); );
form.reset(); form.reset();
router.push(''); router.push('');
@ -75,9 +74,7 @@ const ForgotPassword = () => {
return ( return (
<Card className='min-w-xs md:min-w-sm'> <Card className='min-w-xs md:min-w-sm'>
<CardHeader> <CardHeader>
<CardTitle className='text-2xl font-medium'> <CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
Reset Password
</CardTitle>
<CardDescription className='text-sm text-foreground'> <CardDescription className='text-sm text-foreground'>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'> <Link className='font-medium underline' href='/sign-up'>
@ -119,13 +116,9 @@ const ForgotPassword = () => {
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage <StatusMessage message={{ error: statusMessage }} />
message={{ error: statusMessage }}
/>
) : ( ) : (
<StatusMessage <StatusMessage message={{ success: statusMessage }} />
message={{ success: statusMessage }}
/>
))} ))}
</form> </form>
</Form> </Form>

View File

@ -97,8 +97,7 @@ const ProfilePage = () => {
<CardHeader className='pb-2'> <CardHeader className='pb-2'>
<CardTitle className='text-2xl'>Your Profile</CardTitle> <CardTitle className='text-2xl'>Your Profile</CardTitle>
<CardDescription> <CardDescription>
Manage your personal information and how it appears to Manage your personal information and how it appears to others
others
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{isLoading && !profile ? ( {isLoading && !profile ? (
@ -111,9 +110,7 @@ const ProfilePage = () => {
<Separator /> <Separator />
<ProfileForm onSubmit={handleProfileSubmit} /> <ProfileForm onSubmit={handleProfileSubmit} />
<Separator /> <Separator />
<ResetPasswordForm <ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
onSubmit={handleResetPasswordSubmit}
/>
<Separator /> <Separator />
<SignOut /> <SignOut />
</div> </div>

View File

@ -99,9 +99,7 @@ const Login = () => {
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'> <FormLabel className='text-lg'>Email</FormLabel>
Email
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='email' type='email'
@ -120,9 +118,7 @@ const Login = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='flex justify-between'> <div className='flex justify-between'>
<FormLabel className='text-lg'> <FormLabel className='text-lg'>Password</FormLabel>
Password
</FormLabel>
<Link <Link
className='text-xs text-foreground underline text-right' className='text-xs text-foreground underline text-right'
href='/forgot-password' href='/forgot-password'
@ -146,13 +142,9 @@ const Login = () => {
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage <StatusMessage message={{ error: statusMessage }} />
message={{ error: statusMessage }}
/>
) : ( ) : (
<StatusMessage <StatusMessage message={{ message: statusMessage }} />
message={{ message: statusMessage }}
/>
))} ))}
<SubmitButton <SubmitButton
disabled={isLoading} disabled={isLoading}

View File

@ -104,10 +104,7 @@ const SignUp = () => {
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle> <CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<CardDescription className='text-foreground'> <CardDescription className='text-foreground'>
Already have an account?{' '} Already have an account?{' '}
<Link <Link className='text-primary font-medium underline' href='/sign-in'>
className='text-primary font-medium underline'
href='/sign-in'
>
Sign in Sign in
</Link> </Link>
</CardDescription> </CardDescription>
@ -123,15 +120,9 @@ const SignUp = () => {
name='name' name='name'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'> <FormLabel className='text-lg'>Name</FormLabel>
Name
</FormLabel>
<FormControl> <FormControl>
<Input <Input type='text' placeholder='Full Name' {...field} />
type='text'
placeholder='Full Name'
{...field}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -141,9 +132,7 @@ const SignUp = () => {
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'> <FormLabel className='text-lg'>Email</FormLabel>
Email
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='email' type='email'
@ -160,9 +149,7 @@ const SignUp = () => {
name='password' name='password'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'> <FormLabel className='text-lg'>Password</FormLabel>
Password
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='password' type='password'
@ -179,9 +166,7 @@ const SignUp = () => {
name='confirmPassword' name='confirmPassword'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='text-lg'> <FormLabel className='text-lg'>Confirm Password</FormLabel>
Confirm Password
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='password' type='password'
@ -198,13 +183,9 @@ const SignUp = () => {
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage <StatusMessage message={{ error: statusMessage }} />
message={{ error: statusMessage }}
/>
) : ( ) : (
<StatusMessage <StatusMessage message={{ success: statusMessage }} />
message={{ success: statusMessage }}
/>
))} ))}
<SubmitButton <SubmitButton
className='text-[1.0rem] cursor-pointer' className='text-[1.0rem] cursor-pointer'

View File

@ -27,15 +27,9 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
}, [error]); }, [error]);
return ( return (
<html <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
lang='en'
className={`${geist.variable}`}
suppressHydrationWarning
>
<body <body
className={cn( className={cn('bg-background text-foreground font-sans antialiased')}
'bg-background text-foreground font-sans antialiased',
)}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
@ -53,9 +47,7 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
> >
<NextError statusCode={0} /> <NextError statusCode={0} />
{reset !== undefined && ( {reset !== undefined && (
<Button onClick={() => reset()}> <Button onClick={() => reset()}>Try again</Button>
Try again
</Button>
)} )}
</div> </div>
</div> </div>

View File

@ -15,13 +15,14 @@ import * as Sentry from '@sentry/nextjs';
export const generateMetadata = (): Metadata => { export const generateMetadata = (): Metadata => {
return { return {
title: { title: {
template: '%s | T3 Template', template: '%s | Tech Tracker',
default: 'T3 Template with Supabase', default: 'Tech Tracker',
}, },
description: 'Created by Gib with T3!', description: 'Created by Gib with Next.js & Supabase!',
applicationName: 'T3 Template', applicationName: 'Tech Tracker',
keywords: 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' }], authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown', creator: 'Gib Brown',
publisher: 'Gib Brown', publisher: 'Gib Brown',
@ -341,71 +342,71 @@ export const generateMetadata = (): Metadata => {
other: { other: {
...Sentry.getTraceData(), ...Sentry.getTraceData(),
}, },
twitter: { //twitter: {
card: 'app', //card: 'app',
title: 'T3 Template', //title: 'Tech Tracker',
description: 'Created by Gib with T3!', //description: 'Created by Gib with Next.js & Supabase!',
siteId: '', //siteId: '',
creator: '@cs_gib', //creator: '@cs_gib',
creatorId: '', //creatorId: '',
images: { //images: {
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png', //url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
alt: 'T3 Template', //alt: 'Tech Tracker',
}, //},
app: { //app: {
name: 'T3 Template', //name: 'Tech Tracker',
id: { //id: {
iphone: '', //iphone: '',
ipad: '', //ipad: '',
googleplay: '', //googleplay: '',
}, //},
url: { //url: {
iphone: '', //iphone: '',
ipad: '', //ipad: '',
googleplay: '', //googleplay: '',
}, //},
}, //},
}, //},
verification: { //verification: {
google: 'google', //google: 'google',
yandex: 'yandex', //yandex: 'yandex',
yahoo: 'yahoo', //yahoo: 'yahoo',
}, //},
itunes: { //itunes: {
appId: '', //appId: '',
appArgument: '', //appArgument: '',
}, //},
appleWebApp: { //appleWebApp: {
title: 'T3 Template', //title: 'Tech Tracker',
statusBarStyle: 'black-translucent', //statusBarStyle: 'black-translucent',
startupImage: [ //startupImage: [
'/icons/apple/splash-768x1004.png', //'/icons/apple/splash-768x1004.png',
{ //{
url: '/icons/apple/splash-1536x2008.png', //url: '/icons/apple/splash-1536x2008.png',
media: '(device-width: 768px) and (device-height: 1024px)', //media: '(device-width: 768px) and (device-height: 1024px)',
}, //},
], //],
}, //},
appLinks: { //appLinks: {
ios: { //ios: {
url: 'https://t3-template.gbrown.org/ios', //url: 'https://techtracker.gbrown.org/ios',
app_store_id: 't3_template', //app_store_id: 'com.gbrown.techtracker',
}, //},
android: { //android: {
package: 'org.gbrown.android/t3-template', //package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template', //app_name: 'app_t3_template',
}, //},
web: { //web: {
url: 'https://t3-template.gbrown.org/web', //url: 'https://techtracker.gbrown.org',
should_fallback: true, //should_fallback: true,
}, //},
}, //},
facebook: { //facebook: {
appId: '', //appId: '',
}, //},
pinterest: { //pinterest: {
richPin: true, //richPin: true,
}, //},
category: 'technology', category: 'technology',
}; };
}; };
@ -417,15 +418,9 @@ const geist = Geist({
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return ( return (
<html <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
lang='en'
className={`${geist.variable}`}
suppressHydrationWarning
>
<body <body
className={cn( className={cn('bg-background text-foreground font-sans antialiased')}
'bg-background text-foreground font-sans antialiased',
)}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'

View File

@ -31,9 +31,9 @@ const HomePage = async () => {
Welcome to the T3 Supabase Template! Welcome to the T3 Supabase Template!
</CardTitle> </CardTitle>
<CardDescription className='text-[1.0rem] mb-2'> <CardDescription className='text-[1.0rem] mb-2'>
A great place to start is by creating a new user A great place to start is by creating a new user account &
account & ensuring you can sign up! If you ensuring you can sign up! If you already have an account, go
already have an account, go ahead and sign in! ahead and sign in!
</CardDescription> </CardDescription>
<SignInSignUp <SignInSignUp
className='flex gap-4 w-full justify-center' className='flex gap-4 w-full justify-center'
@ -42,9 +42,7 @@ const HomePage = async () => {
/> />
<div className='flex items-center w-full gap-4'> <div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' /> <Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'> <span className='text-sm text-muted-foreground'>or</span>
or
</span>
<Separator className='flex-1 bg-accent py-0.5' /> <Separator className='flex-1 bg-accent py-0.5' />
</div> </div>
<div className='flex gap-4'> <div className='flex gap-4'>
@ -55,8 +53,8 @@ const HomePage = async () => {
<Separator className='bg-accent' /> <Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'> <CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
<CardTitle className='text-lg mb-6 w-2/3 text-center'> <CardTitle className='text-lg mb-6 w-2/3 text-center'>
You can also test out your connection to Sentry You can also test out your connection to Sentry if you want to
if you want to start there! start there!
</CardTitle> </CardTitle>
<TestSentryCard /> <TestSentryCard />
</CardContent> </CardContent>

View File

@ -161,9 +161,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
} catch (error) { } catch (error) {
console.error('Error updating profile:', error); console.error('Error updating profile:', error);
toast.error( toast.error(
error instanceof Error error instanceof Error ? error.message : 'Failed to update profile',
? error.message
: 'Failed to update profile',
); );
return { success: false, error }; return { success: false, error };
} }
@ -185,9 +183,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
refreshUserData, refreshUserData,
}; };
return ( return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}; };
export const useAuth = () => { export const useAuth = () => {

View File

@ -1,4 +1,4 @@
'use server'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui'; import { Button, type buttonVariants } from '@/components/ui';
@ -13,7 +13,7 @@ type SignInSignUpProps = {
signUpVariant?: VariantProps<typeof buttonVariants>['variant']; signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
}; };
export const SignInSignUp = async ({ export const SignInSignUp = ({
className = 'flex gap-2', className = 'flex gap-2',
signInSize = 'default', signInSize = 'default',
signUpSize = 'sm', signUpSize = 'sm',

View File

@ -71,9 +71,7 @@ export const SignInWithApple = ({
<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 && <StatusMessage message={{ error: statusMessage }} />}
<StatusMessage message={{ error: statusMessage }} />
)}
</form> </form>
); );
}; };

View File

@ -64,9 +64,7 @@ export const SignInWithMicrosoft = ({
<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 && <StatusMessage message={{ error: statusMessage }} />}
<StatusMessage message={{ error: statusMessage }} />
)}
</form> </form>
); );
}; };

View File

@ -1,22 +1,17 @@
'use server'; 'use client';
import { getProfile } from '@/lib/actions';
import AvatarDropdown from './AvatarDropdown'; import AvatarDropdown from './AvatarDropdown';
import { SignInSignUp } from '@/components/default/auth'; import { SignInSignUp } from '@/components/default/auth';
import { useAuth } from '@/components/context';
const NavigationAuth = async () => { const NavigationAuth = () => {
try { const { isAuthenticated } = useAuth();
const profile = await getProfile(); return isAuthenticated ? (
return profile.success ? ( <div className='flex items-center gap-4'>
<div className='flex items-center gap-4'> <AvatarDropdown />
<AvatarDropdown /> </div>
</div> ) : (
) : ( <SignInSignUp />
<SignInSignUp /> );
);
} catch (error) {
console.error(`Error getting profile: ${error as string}`);
return <SignInSignUp />;
}
}; };
export default NavigationAuth; export default NavigationAuth;

View File

@ -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 Image from 'next/image';
import Link from 'next/link';
import NavigationAuth from './auth';
import { ThemeToggle, TVToggle, useTVMode } from '@/components/context';
const Navigation = () => { 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 ( return (
<nav <nav
className='w-full flex justify-center className='w-full flex justify-center
@ -18,23 +27,9 @@ const Navigation = () => {
> >
<div className='flex gap-5 items-center font-semibold'> <div className='flex gap-5 items-center font-semibold'>
<Link className='flex flex-row my-auto gap-2' href='/'> <Link className='flex flex-row my-auto gap-2' href='/'>
<Image <Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
src='/favicon.png' <h1 className='my-auto text-2xl'>Tech Tracker</h1>
alt='T3 Logo'
width={50}
height={50}
/>
<h1 className='my-auto text-2xl'>
T3 Supabase Template
</h1>
</Link> </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>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<TVToggle /> <TVToggle />

View File

@ -55,10 +55,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
return ( return (
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
>
<FormField <FormField
control={form.control} control={form.control}
name='full_name' name='full_name'
@ -68,9 +65,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>Your public display name.</FormDescription>
Your public display name.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -86,8 +81,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Your email address associated with your Your email address associated with your account.
account.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -95,10 +89,7 @@ export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
/> />
<div className='flex justify-center'> <div className='flex justify-center'>
<SubmitButton <SubmitButton disabled={isLoading} pendingText='Saving...'>
disabled={isLoading}
pendingText='Saving...'
>
Save Changes Save Changes
</SubmitButton> </SubmitButton>
</div> </div>

View File

@ -69,9 +69,7 @@ export const ResetPasswordForm = ({
} }
} catch (error) { } catch (error) {
setStatusMessage( setStatusMessage(
error instanceof Error error instanceof Error ? error.message : 'Password was not updated!',
? error.message
: 'Password was not updated!',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -102,8 +100,7 @@ export const ResetPasswordForm = ({
<Input type='password' {...field} /> <Input type='password' {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter your new password. Must be at Enter your new password. Must be at least 8 characters.
least 8 characters.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -119,8 +116,7 @@ export const ResetPasswordForm = ({
<Input type='password' {...field} /> <Input type='password' {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Please re-enter your new password to Please re-enter your new password to confirm.
confirm.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -131,13 +127,9 @@ export const ResetPasswordForm = ({
statusMessage.includes('error') || statusMessage.includes('error') ||
statusMessage.includes('failed') || statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? ( statusMessage.includes('invalid') ? (
<StatusMessage <StatusMessage message={{ error: statusMessage }} />
message={{ error: statusMessage }}
/>
) : ( ) : (
<StatusMessage <StatusMessage message={{ message: statusMessage }} />
message={{ message: statusMessage }}
/>
))} ))}
<div className='flex justify-center'> <div className='flex justify-center'>
<SubmitButton <SubmitButton

View File

@ -69,9 +69,7 @@ export const TestSentryCard = () => {
fill='currentcolor' fill='currentcolor'
/> />
</svg> </svg>
<CardTitle className='text-3xl my-auto'> <CardTitle className='text-3xl my-auto'>Test Sentry</CardTitle>
Test Sentry
</CardTitle>
</div> </div>
<CardDescription className='text-[1.0rem]'> <CardDescription className='text-[1.0rem]'>
Click the button below & view the sample error on{' '} Click the button below & view the sample error on{' '}
@ -81,8 +79,8 @@ export const TestSentryCard = () => {
> >
the Sentry website the Sentry website
</Link> </Link>
. Navigate to the {"'"}Issues{"'"} page & you should see the . Navigate to the {"'"}Issues{"'"} page & you should see the sample
sample error! error!
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -97,20 +95,14 @@ export const TestSentryCard = () => {
{hasSentError ? ( {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'> <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' /> <CheckCircle size={30} className='my-auto' />
<p className='text-lg'> <p className='text-lg'>Sample error was sent to Sentry!</p>
Sample error was sent to Sentry!
</p>
</div> </div>
) : !isConnected ? ( ) : !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'> <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 <MessageCircleWarning size={40} className='my-auto' />
size={40}
className='my-auto'
/>
<p> <p>
Wait! The Sentry SDK is not able to reach Sentry Wait! The Sentry SDK is not able to reach Sentry right now -
right now - this may be due to an adblocker. For this may be due to an adblocker. For more information, see{' '}
more information, see{' '}
<Link <Link
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data' 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' className='text-accent-foreground underline hover:text-primary'
@ -125,8 +117,8 @@ export const TestSentryCard = () => {
</div> </div>
<Separator className='my-4 bg-accent' /> <Separator className='my-4 bg-accent' />
<p className='description'> <p className='description'>
Warning! Sometimes Adblockers will prevent errors from being Warning! Sometimes Adblockers will prevent errors from being sent to
sent to Sentry. Sentry.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -57,9 +57,9 @@ export const FetchDataSteps = () => {
> >
Table Editor Table Editor
</a>{' '} </a>{' '}
for your Supabase project to create a table and insert some for your Supabase project to create a table and insert some example
example data. If you&apos;re stuck for creativity, you can data. If you&apos;re stuck for creativity, you can copy and paste the
copy and paste the following into the{' '} following into the{' '}
<a <a
href='https://supabase.com/dashboard/project/_/sql/new' href='https://supabase.com/dashboard/project/_/sql/new'
className='font-bold hover:underline text-foreground/80' className='font-bold hover:underline text-foreground/80'
@ -75,8 +75,8 @@ export const FetchDataSteps = () => {
<TutorialStep title='Query Supabase data from Next.js'> <TutorialStep title='Query Supabase data from Next.js'>
<p> <p>
To create a Supabase client and query data from an Async To create a Supabase client and query data from an Async Server
Server Component, create a new page.tsx file at{' '} 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'> <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 /app/notes/page.tsx
</span>{' '} </span>{' '}

View File

@ -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', '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: secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', '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', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {

View File

@ -16,10 +16,7 @@ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal <DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
data-slot='dropdown-menu-portal'
{...props}
/>
); );
} }
@ -58,10 +55,7 @@ function DropdownMenuGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group <DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
data-slot='dropdown-menu-group'
{...props}
/>
); );
} }
@ -201,9 +195,7 @@ function DropdownMenuShortcut({
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return ( return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
);
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({

View File

@ -40,8 +40,7 @@ export const env = createEnv({
CI: process.env.CI, CI: process.env.CI,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL, NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,

View File

@ -124,9 +124,7 @@ export const uploadFile = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error uploading file',
? error.message
: 'Unknown error uploading file',
}; };
} }
}; };
@ -149,9 +147,7 @@ export const replaceFile = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error replacing file',
? error.message
: 'Unknown error replacing file',
}; };
} }
}; };
@ -175,9 +171,7 @@ export const deleteFile = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error deleting file',
? error.message
: 'Unknown error deleting file',
}; };
} }
}; };
@ -211,9 +205,7 @@ export const listFiles = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error listing files',
? error.message
: 'Unknown error listing files',
}; };
} }
}; };

View File

@ -124,9 +124,7 @@ export const uploadFile = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error uploading file',
? error.message
: 'Unknown error uploading file',
}; };
} }
}; };
@ -152,9 +150,7 @@ export const replaceFile = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error replacing file',
? error.message
: 'Unknown error replacing file',
}; };
} }
}; };
@ -178,9 +174,7 @@ export const deleteFile = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error deleting file',
? error.message
: 'Unknown error deleting file',
}; };
} }
}; };
@ -214,9 +208,7 @@ export const listFiles = async ({
return { return {
success: false, success: false,
error: error:
error instanceof Error error instanceof Error ? error.message : 'Unknown error listing files',
? error.message
: 'Unknown error listing files',
}; };
} }
}; };

View File

@ -72,9 +72,7 @@ export const useFileUpload = () => {
}); });
if (!uploadResult.success) { if (!uploadResult.success) {
throw new Error( throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
uploadResult.error || `Failed to upload to ${bucket}`,
);
} }
return { success: true, data: uploadResult.data }; return { success: true, data: uploadResult.data };

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

View File

@ -41,10 +41,7 @@ export const updateSession = async (
const user = await supabase.auth.getUser(); const user = await supabase.auth.getUser();
// protected routes // protected routes
if ( if (request.nextUrl.pathname.startsWith('/reset-password') && user.error) {
request.nextUrl.pathname.startsWith('/reset-password') &&
user.error
) {
return NextResponse.redirect(new URL('/sign-in', request.url)); return NextResponse.redirect(new URL('/sign-in', request.url));
} }