Update Convex with no payload to be just like convex with payload but without payload
This commit is contained in:
File diff suppressed because one or more lines are too long
+10
-9
@@ -4,24 +4,25 @@
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
|
||||
"dev": "expo start",
|
||||
"dev:tunnel": "expo start --tunnel",
|
||||
"dev:android": "expo start --android",
|
||||
"dev:ios": "expo start --ios",
|
||||
"dev": "bun with-env expo start",
|
||||
"dev:tunnel": "bun with-env expo start --tunnel",
|
||||
"dev:android": "bun with-env expo start --android",
|
||||
"dev:ios": "bun with-env expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@gib/backend": "workspace:*",
|
||||
"@legendapp/list": "^2.0.19",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/elements": "^2.9.10",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@react-navigation/bottom-tabs": "^7.15.8",
|
||||
"@react-navigation/elements": "^2.9.13",
|
||||
"@react-navigation/native": "^7.2.1",
|
||||
"@sentry/react-native": "^7.13.0",
|
||||
"convex": "catalog:convex",
|
||||
"expo": "~54.0.33",
|
||||
@@ -45,7 +46,7 @@
|
||||
"react-native": "~0.81.6",
|
||||
"react-native-css": "3.0.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.6",
|
||||
"react-native-reanimated": "~4.1.7",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.2",
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-18303
File diff suppressed because one or more lines are too long
+15
-6
@@ -5,24 +5,28 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bun with-env next build",
|
||||
"build:env": "bun with-env next build",
|
||||
"build:docker": "next build --webpack",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "bun with-env next dev --turbo",
|
||||
"dev:tunnel": "bun with-env next dev --turbo",
|
||||
"dev:web": "bun with-env next dev --webpack",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
"start": "bun with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
"test:unit": "NODE_ENV=test vitest run --project unit",
|
||||
"test:integration": "NODE_ENV=test vitest run --project integration",
|
||||
"test:component": "NODE_ENV=test vitest run --project component",
|
||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@gib/backend": "workspace:*",
|
||||
"@gib/ui": "workspace:*",
|
||||
"@sentry/nextjs": "^10.43.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"convex": "catalog:convex",
|
||||
"next": "^16.1.7",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
@@ -35,14 +39,19 @@
|
||||
"@gib/prettier-config": "workspace:*",
|
||||
"@gib/tailwind-config": "workspace:*",
|
||||
"@gib/tsconfig": "workspace:*",
|
||||
"@gib/vitest-config": "workspace:*",
|
||||
"@testing-library/react": "catalog:test",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:react19",
|
||||
"@types/react-dom": "catalog:react19",
|
||||
"baseline-browser-mapping": "^2.10.11",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "catalog:test",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:test"
|
||||
},
|
||||
"prettier": "@gib/prettier-config"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Profile',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ProfileLayout = ({
|
||||
const ProfileLayout = async ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
|
||||
return <>{children}</>;
|
||||
};
|
||||
export default ProfileLayout;
|
||||
|
||||
@@ -159,7 +159,7 @@ const SignIn = () => {
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (
|
||||
values: z.infer<typeof verifyEmailFormSchema>,
|
||||
_values: z.infer<typeof verifyEmailFormSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
@@ -194,7 +194,7 @@ const SignIn = () => {
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
render={({ field: _field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
+10
-10
@@ -1,12 +1,12 @@
|
||||
import { CTA, Features, Hero, TechStack } from '@/components/landing';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className='flex min-h-screen flex-col'>
|
||||
<Hero />
|
||||
<Features />
|
||||
<TechStack />
|
||||
<CTA />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const Home = () => (
|
||||
<main className='flex min-h-screen flex-col'>
|
||||
<Hero />
|
||||
<Features />
|
||||
<TechStack />
|
||||
<CTA />
|
||||
</main>
|
||||
);
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@import 'tw-animate-css';
|
||||
@import '@gib/tailwind-config/theme';
|
||||
|
||||
@source '../../../../packages/ui/src/*.{ts,tsx}';
|
||||
@source '../../../../../packages/ui/src/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@custom-variant light (&:where(.light, .light *));
|
||||
@@ -23,7 +23,11 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
@apply bg-background;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export const CTA = () => (
|
||||
everything pre-configured.
|
||||
</p>
|
||||
|
||||
{/* Quick Start Command */}
|
||||
<div className='mt-12'>
|
||||
<p className='text-muted-foreground mb-3 text-sm font-medium'>
|
||||
Quick Start
|
||||
|
||||
@@ -60,7 +60,6 @@ const features = [
|
||||
export const Features = () => (
|
||||
<section id='features' className='container mx-auto px-4 py-24'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
{/* Section Header */}
|
||||
<div className='mb-16 text-center'>
|
||||
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
|
||||
Everything You Need to Ship Fast
|
||||
@@ -71,7 +70,6 @@ export const Features = () => (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.title} className='border-border/40'>
|
||||
|
||||
@@ -9,16 +9,16 @@ const kanitSans = Kanit({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const highlights = ['TypeScript', 'Self-Hosted', 'Real-time', 'Auth Included'];
|
||||
|
||||
export const Hero = () => (
|
||||
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
|
||||
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
|
||||
{/* Badge */}
|
||||
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
|
||||
<span className='mr-2'>🚀</span>
|
||||
<span>Production-ready monorepo template</span>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
|
||||
Build Full-Stack Apps with{' '}
|
||||
<span
|
||||
@@ -28,14 +28,12 @@ export const Hero = () => (
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
|
||||
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web
|
||||
and mobile apps faster with shared code, type-safe backend, and complete
|
||||
control over your infrastructure.
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<Button size='lg' variant='outline' asChild>
|
||||
<Link
|
||||
@@ -54,72 +52,25 @@ export const Hero = () => (
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Features Quick List */}
|
||||
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='h-5 w-5 text-green-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M5 13l4 4L19 7'
|
||||
/>
|
||||
</svg>
|
||||
<span>TypeScript</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='h-5 w-5 text-green-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M5 13l4 4L19 7'
|
||||
/>
|
||||
</svg>
|
||||
<span>Self-Hosted</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='h-5 w-5 text-green-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M5 13l4 4L19 7'
|
||||
/>
|
||||
</svg>
|
||||
<span>Real-time</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='h-5 w-5 text-green-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M5 13l4 4L19 7'
|
||||
/>
|
||||
</svg>
|
||||
<span>Auth Included</span>
|
||||
</div>
|
||||
{highlights.map((highlight) => (
|
||||
<div key={highlight} className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='h-5 w-5 text-green-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M5 13l4 4L19 7'
|
||||
/>
|
||||
</svg>
|
||||
<span>{highlight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -29,7 +29,7 @@ const techStack = [
|
||||
technologies: [
|
||||
{ name: 'Turborepo', description: 'High-performance build system' },
|
||||
{ name: 'TypeScript', description: 'Type-safe development' },
|
||||
{ name: 'Bun', description: 'Fast package manager & runtime' },
|
||||
{ name: 'Bun', description: 'Fast package manager and runtime' },
|
||||
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
|
||||
{ name: 'Docker', description: 'Containerized deployment' },
|
||||
],
|
||||
@@ -40,7 +40,6 @@ export const TechStack = () => (
|
||||
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
|
||||
<div className='container mx-auto px-4 py-24'>
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
{/* Section Header */}
|
||||
<div className='mb-16 text-center'>
|
||||
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
|
||||
Modern Tech Stack
|
||||
@@ -51,7 +50,6 @@ export const TechStack = () => (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tech Stack Grid */}
|
||||
<div className='grid gap-12 md:grid-cols-3'>
|
||||
{techStack.map((stack) => (
|
||||
<div key={stack.category}>
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
Input,
|
||||
} from '@gib/ui';
|
||||
|
||||
interface AvatarUploadProps {
|
||||
type AvatarUploadProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
}
|
||||
};
|
||||
|
||||
const dataUrlToBlob = async (
|
||||
dataUrl: string,
|
||||
|
||||
@@ -1,17 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Kanit } from 'next/font/google';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Coffee, Server, Wrench } from 'lucide-react';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
import { Coffee, Server, User, Wrench } from 'lucide-react';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
|
||||
import type { NavItem } from './navigation';
|
||||
import { Controls } from './controls';
|
||||
import { DesktopNavigation, MobileNavigation } from './navigation';
|
||||
|
||||
const kanitSans = Kanit({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
export default function Header(headerProps: ComponentProps<'header'>) {
|
||||
const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: '/#features',
|
||||
icon: Wrench,
|
||||
label: 'Features',
|
||||
},
|
||||
{
|
||||
href: '/#tech-stack',
|
||||
icon: Server,
|
||||
label: 'Stack',
|
||||
},
|
||||
{
|
||||
href: 'https://git.gbrown.org/gib/convex-monorepo',
|
||||
icon: Coffee,
|
||||
label: 'Repository',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (user?.isAdmin) {
|
||||
navItems.push({
|
||||
href: '/admin',
|
||||
icon: User,
|
||||
label: 'Admin',
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className='border-border/40 bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur'
|
||||
@@ -28,45 +65,24 @@ export default function Header(headerProps: ComponentProps<'header'>) {
|
||||
alt='Convex Monorepo'
|
||||
width={50}
|
||||
height={50}
|
||||
className='w-15 invert dark:invert-0'
|
||||
className='w-10 invert lg:w-15 dark:invert-0'
|
||||
/>
|
||||
<span
|
||||
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}
|
||||
className={`hidden font-extrabold sm:mb-1 sm:text-lg md:inline lg:mb-3 lg:text-4xl xl:text-5xl ${kanitSans.className}`}
|
||||
>
|
||||
convex monorepo
|
||||
convex-monorepo
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className='hidden items-center gap-6 text-base font-medium md:flex'>
|
||||
<Link
|
||||
href='/#features'
|
||||
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
|
||||
>
|
||||
<Wrench width={18} height={18} />
|
||||
Features
|
||||
</Link>
|
||||
<Link
|
||||
href='/#tech-stack'
|
||||
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
|
||||
>
|
||||
<Server width={18} height={18} />
|
||||
Stack
|
||||
</Link>
|
||||
<Link
|
||||
href='https://git.gbrown.org/gib/convex-monorepo'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
|
||||
>
|
||||
<Coffee width={20} height={20} />
|
||||
Repository
|
||||
</Link>
|
||||
</nav>
|
||||
<DesktopNavigation items={navItems} />
|
||||
|
||||
{/* Controls (Theme + Auth) */}
|
||||
<Controls />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Controls />
|
||||
<MobileNavigation items={navItems} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Menu } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@gib/ui';
|
||||
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type NavigationProps = {
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
const DesktopNavigation = ({ items }: NavigationProps) => {
|
||||
return (
|
||||
<nav className='hidden items-center gap-4 text-xs font-medium sm:flex md:gap-6 lg:text-base'>
|
||||
{items.map(({ href, icon: Icon, label, external }) => (
|
||||
<Link
|
||||
key={label}
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
|
||||
>
|
||||
<Icon width={18} height={18} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNavigation = ({ items }: NavigationProps) => {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon-sm'
|
||||
className='sm:hidden'
|
||||
aria-label='Open navigation menu'
|
||||
>
|
||||
<Menu className='size-4.5' />
|
||||
<span className='sr-only'>Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side='right' className='w-[min(88vw,22rem)] px-0'>
|
||||
<SheetHeader className='border-border/60 from-background to-muted/40 border-b bg-linear-to-br from-35% px-5 py-5 text-left'>
|
||||
<SheetTitle className='text-left text-lg'>Navigation</SheetTitle>
|
||||
<SheetDescription className='text-left'>
|
||||
Quick access to the links that collapse out of the header.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex flex-col gap-3 px-4 py-5'>
|
||||
{items.map(({ href, icon: Icon, label, external }) => (
|
||||
<SheetClose asChild key={label}>
|
||||
<Link
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className='bg-card hover:bg-muted/70 border-border/60 text-card-foreground flex items-center justify-between rounded-2xl border px-4 py-3 transition-colors'
|
||||
>
|
||||
<span className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-foreground flex h-9 w-9 items-center justify-center rounded-xl'>
|
||||
<Icon className='size-4.5' />
|
||||
</span>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
</span>
|
||||
{external ? (
|
||||
<ExternalLink className='text-muted-foreground size-4' />
|
||||
) : null}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export { DesktopNavigation, MobileNavigation };
|
||||
@@ -19,7 +19,7 @@ Sentry.init({
|
||||
tracesSampleRate: 1,
|
||||
enableLogs: true,
|
||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||
replaysSessionSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.5,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { NextResponse } from 'next/server';
|
||||
// In-memory stores for tracking IPs (use Redis in production)
|
||||
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
const ip404Attempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
const bannedIPs = new Set<string>();
|
||||
// Map of ip -> ban expiry timestamp. Avoids setTimeout closures leaking on hot reload.
|
||||
const bannedIPs = new Map<string, number>();
|
||||
|
||||
// Suspicious patterns that indicate malicious activity
|
||||
const MALICIOUS_PATTERNS = [
|
||||
@@ -72,6 +73,36 @@ const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
|
||||
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
|
||||
|
||||
let lastCleanup = Date.now();
|
||||
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Lazily purge stale entries so Maps don't grow without bound.
|
||||
// Called on every request but only iterates Maps every CLEANUP_INTERVAL.
|
||||
const cleanupStaleMaps = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
for (const [ip, data] of ipAttempts.entries()) {
|
||||
if (now - data.lastAttempt > RATE_LIMIT_WINDOW) ipAttempts.delete(ip);
|
||||
}
|
||||
for (const [ip, data] of ip404Attempts.entries()) {
|
||||
if (now - data.lastAttempt > RATE_404_WINDOW) ip404Attempts.delete(ip);
|
||||
}
|
||||
for (const [ip, expiry] of bannedIPs.entries()) {
|
||||
if (now > expiry) bannedIPs.delete(ip);
|
||||
}
|
||||
};
|
||||
|
||||
const isIPBanned = (ip: string): boolean => {
|
||||
const expiry = bannedIPs.get(ip);
|
||||
if (expiry === undefined) return false;
|
||||
if (Date.now() > expiry) {
|
||||
bannedIPs.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const getClientIP = (request: NextRequest): string => {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const realIP = request.headers.get('x-real-ip');
|
||||
@@ -104,13 +135,8 @@ const updateIPAttempts = (ip: string): boolean => {
|
||||
attempts.lastAttempt = now;
|
||||
|
||||
if (attempts.count > MAX_ATTEMPTS) {
|
||||
bannedIPs.add(ip);
|
||||
bannedIPs.set(ip, Date.now() + BAN_DURATION);
|
||||
ipAttempts.delete(ip);
|
||||
|
||||
setTimeout(() => {
|
||||
bannedIPs.delete(ip);
|
||||
}, BAN_DURATION);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -130,17 +156,13 @@ const update404Attempts = (ip: string): boolean => {
|
||||
attempts.lastAttempt = now;
|
||||
|
||||
if (attempts.count > MAX_404_ATTEMPTS) {
|
||||
bannedIPs.add(ip);
|
||||
bannedIPs.set(ip, Date.now() + BAN_DURATION);
|
||||
ip404Attempts.delete(ip);
|
||||
|
||||
console.log(
|
||||
`🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
bannedIPs.delete(ip);
|
||||
}, BAN_DURATION);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -148,12 +170,14 @@ const update404Attempts = (ip: string): boolean => {
|
||||
};
|
||||
|
||||
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
|
||||
cleanupStaleMaps();
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
const method = request.method;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
// Check if IP is already banned
|
||||
if (bannedIPs.has(ip)) {
|
||||
if (isIPBanned(ip)) {
|
||||
return new NextResponse('Access denied.', { status: 403 });
|
||||
}
|
||||
|
||||
@@ -183,7 +207,7 @@ export const handle404Response = (
|
||||
): NextResponse | null => {
|
||||
const ip = getClientIP(request);
|
||||
|
||||
if (bannedIPs.has(ip)) {
|
||||
if (isIPBanned(ip)) {
|
||||
return new NextResponse('Access denied.', { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
+21
-5
@@ -1,19 +1,36 @@
|
||||
import { convexAuthNextjsMiddleware } from '@convex-dev/auth/nextjs/server';
|
||||
|
||||
export default convexAuthNextjsMiddleware();
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
'/((?!.*\\..*|_next).*)',
|
||||
'/',
|
||||
'/(api)(.*)',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
import { banSuspiciousIPs } from '@/lib/proxy/ban-sus-ips';
|
||||
import {
|
||||
convexAuthNextjsMiddleware,
|
||||
createRouteMatcher,
|
||||
nextjsMiddlewareRedirect,
|
||||
} from '@convex-dev/auth/nextjs/server';
|
||||
|
||||
const isSignInPage = createRouteMatcher(['/sign-in']);
|
||||
const isProtectedRoute = createRouteMatcher(['/profile']);
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
'/profile(.*)',
|
||||
'/dashboard(.*)',
|
||||
'/admin-panel(.*)',
|
||||
]);
|
||||
|
||||
export default convexAuthNextjsMiddleware(
|
||||
async (request, { convexAuth }) => {
|
||||
const banResponse = banSuspiciousIPs(request);
|
||||
if (banResponse) return banResponse;
|
||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/');
|
||||
return nextjsMiddlewareRedirect(request, '/profile');
|
||||
}
|
||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/sign-in');
|
||||
@@ -23,8 +40,6 @@ export default convexAuthNextjsMiddleware(
|
||||
);
|
||||
|
||||
export const config = {
|
||||
// The following matcher runs middleware on all routes
|
||||
// except static assets.
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
'/((?!.*\\..*|_next).*)',
|
||||
@@ -32,3 +47,4 @@ export const config = {
|
||||
'/(api)(.*)',
|
||||
],
|
||||
};
|
||||
**/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const Greeting = ({ name }: { name: string }) => <p>Hello {name}</p>;
|
||||
|
||||
describe('component test harness', () => {
|
||||
it('renders through jsdom and Testing Library', () => {
|
||||
render(<Greeting name='world' />);
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('integration test harness', () => {
|
||||
it('provides modern Node globals', () => {
|
||||
expect(typeof globalThis.structuredClone).toBe('function');
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('unit test harness', () => {
|
||||
it('executes isolated assertions', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
import { jsdomProject, nodeProject } from '@gib/vitest-config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']),
|
||||
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
|
||||
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user