authentik now working with convex auth
This commit is contained in:
281
AGENTS.md
Normal file
281
AGENTS.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# AGENTS.md - Convex Turbo Monorepo
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun dev # Run all apps (Next.js + Expo + Backend)
|
||||
bun dev:next # Run Next.js + Convex backend only
|
||||
bun dev:expo # Run Expo + Convex backend only
|
||||
bun dev:backend # Run Convex backend only
|
||||
bun dev:expo:tunnel # Expo with tunnel for physical device
|
||||
|
||||
# Quality
|
||||
bun lint # Lint all packages
|
||||
bun lint:fix # Lint and auto-fix
|
||||
bun format # Check formatting
|
||||
bun format:fix # Fix formatting
|
||||
bun typecheck # TypeScript type checking
|
||||
|
||||
# Build
|
||||
bun build # Build all packages
|
||||
|
||||
# Single Package Commands (use Turborepo filters)
|
||||
bun turbo run dev -F @gib/next # Single app dev
|
||||
bun turbo run lint -F @gib/backend # Lint single package
|
||||
bun turbo run typecheck -F @gib/ui # Typecheck single package
|
||||
|
||||
# Cleanup
|
||||
bun clean # Clean all node_modules (git clean)
|
||||
bun clean:ws # Clean workspace caches
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
convex-monorepo/
|
||||
├── apps/
|
||||
│ ├── next/ # Next.js 16 web app (@gib/next)
|
||||
│ └── expo/ # Expo 54 mobile app (@gib/expo)
|
||||
├── packages/
|
||||
│ ├── backend/ # Convex backend (@gib/backend)
|
||||
│ │ └── convex/ # Convex functions, schema, auth
|
||||
│ └── ui/ # Shared shadcn/ui components (@gib/ui)
|
||||
├── tools/
|
||||
│ ├── eslint/ # @gib/eslint-config
|
||||
│ ├── prettier/ # @gib/prettier-config
|
||||
│ ├── tailwind/ # @gib/tailwind-config
|
||||
│ └── typescript/ # @gib/tsconfig
|
||||
├── docker/ # Self-hosted Convex deployment
|
||||
└── .env # Central environment variables
|
||||
```
|
||||
|
||||
## Dependency Management
|
||||
|
||||
### Catalogs (Single Source of Truth)
|
||||
|
||||
All shared dependencies are defined in root `package.json` catalogs:
|
||||
|
||||
```json
|
||||
"catalog": {
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"eslint": "^9.38.0"
|
||||
},
|
||||
"catalogs": {
|
||||
"convex": { "convex": "^1.28.0", "@convex-dev/auth": "^0.0.81" },
|
||||
"react19": { "react": "^19.1.4", "react-dom": "19.1.4" }
|
||||
}
|
||||
```
|
||||
|
||||
### Using Catalogs in Packages
|
||||
|
||||
```json
|
||||
"dependencies": {
|
||||
"convex": "catalog:convex",
|
||||
"react": "catalog:react19",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gib/eslint-config": "workspace:*",
|
||||
"@gib/ui": "workspace:*"
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Dependencies
|
||||
|
||||
**IMPORTANT:** Do NOT use `bun update` directly - it may replace catalog: with versions.
|
||||
|
||||
```bash
|
||||
# Correct workflow:
|
||||
1. Edit version in root package.json catalog section
|
||||
2. Run: bun install
|
||||
3. Verify with: bun lint:ws (runs sherif)
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports (via @gib/prettier-config)
|
||||
|
||||
```typescript
|
||||
// Order: Types → React → Next/Expo → Third-party → @gib/* → Local
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/convex/_generated/api';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
import { cn } from '@gib/ui';
|
||||
|
||||
import type { User } from './types';
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Strict mode enabled (`noUncheckedIndexedAccess: true`)
|
||||
- Use `type` imports: `import type { X } from 'y'`
|
||||
- Prefix unused vars with `_`: `const _unused = ...`
|
||||
- Avoid `any` - use `unknown` with type guards
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Components: `PascalCase` (`UserProfile.tsx`)
|
||||
- Functions/variables: `camelCase`
|
||||
- Constants: `SCREAMING_SNAKE_CASE`
|
||||
- Files: `kebab-case.ts` (except components)
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// Convex functions - use ConvexError
|
||||
import { ConvexError } from 'convex/values';
|
||||
throw new ConvexError('User not found.');
|
||||
|
||||
// Client-side - handle gracefully
|
||||
try { ... } catch (e) {
|
||||
if (e instanceof ConvexError) { /* handle */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Formatting
|
||||
|
||||
- Single quotes, trailing commas
|
||||
- 80 char line width, 2 space indent
|
||||
- Tailwind classes sorted via prettier-plugin-tailwindcss
|
||||
- Use `cn()` for conditional classes: `cn('base', condition && 'active')`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Central .env (root)
|
||||
|
||||
All env vars in `/.env`. Apps load via `with-env` script:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"dev": "bun with-env next dev --turbo",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
}
|
||||
```
|
||||
|
||||
### Required Variables
|
||||
|
||||
```bash
|
||||
# Convex
|
||||
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=<generated>
|
||||
CONVEX_SITE_URL=https://convex.example.com
|
||||
NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com
|
||||
|
||||
# Auth (sync to Convex deployment)
|
||||
AUTH_AUTHENTIK_ID=
|
||||
AUTH_AUTHENTIK_SECRET=
|
||||
AUTH_AUTHENTIK_ISSUER=
|
||||
USESEND_API_KEY=
|
||||
```
|
||||
|
||||
### Syncing to Convex Deployment
|
||||
|
||||
```bash
|
||||
# Upload env vars to self-hosted Convex
|
||||
npx convex env set AUTH_AUTHENTIK_ID "value"
|
||||
npx convex env set AUTH_AUTHENTIK_SECRET "value"
|
||||
npx convex env set AUTH_AUTHENTIK_ISSUER "value"
|
||||
npx convex env set USESEND_API_KEY "value"
|
||||
npx convex env set CONVEX_SITE_URL "https://convex.example.com"
|
||||
```
|
||||
|
||||
## Convex Backend Patterns
|
||||
|
||||
### Schema (`packages/backend/convex/schema.ts`)
|
||||
|
||||
```typescript
|
||||
import { defineSchema, defineTable } from 'convex/server';
|
||||
import { authTables } from '@convex-dev/auth/server';
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
users: defineTable({ ... }).index('email', ['email']),
|
||||
});
|
||||
```
|
||||
|
||||
### Queries & Mutations
|
||||
|
||||
```typescript
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { mutation, query } from './_generated/server';
|
||||
|
||||
export const getUser = query({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.userId);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Auth
|
||||
|
||||
```typescript
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
|
||||
// In any query/mutation:
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
```
|
||||
|
||||
## Next.js Patterns
|
||||
|
||||
### Convex Provider Setup
|
||||
|
||||
```tsx
|
||||
// src/components/providers/ConvexClientProvider.tsx
|
||||
'use client';
|
||||
|
||||
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
|
||||
import { ConvexReactClient } from 'convex/react';
|
||||
|
||||
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
|
||||
|
||||
export const ConvexClientProvider = ({ children }) => (
|
||||
<ConvexAuthNextjsProvider client={convex}>
|
||||
{children}
|
||||
</ConvexAuthNextjsProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### Path Aliases
|
||||
|
||||
- `@/*` → `./src/*` (Next.js)
|
||||
- `~/*` → `./src/*` (Expo)
|
||||
|
||||
## Expo Patterns
|
||||
|
||||
### NativeWind (Tailwind for RN)
|
||||
|
||||
```tsx
|
||||
import '../styles.css';
|
||||
|
||||
// Use className like web: <View className="flex-1 bg-background" />
|
||||
```
|
||||
|
||||
### Secure Storage for Auth
|
||||
|
||||
```typescript
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
// Tokens stored via SecureStore, not AsyncStorage
|
||||
```
|
||||
|
||||
## Known Issues / Cleanup Needed
|
||||
|
||||
1. **Apps reference @acme/_ instead of @gib/_** - Legacy T3 template imports
|
||||
2. **TRPC imports exist but no TRPC** - Replace with Convex client
|
||||
3. **Expo uses better-auth** - Should use @convex-dev/auth
|
||||
4. **@gib/ui uses pnpm dlx** - Should use bunx for shadcn
|
||||
|
||||
## Adding UI Components
|
||||
|
||||
```bash
|
||||
bun ui-add # Interactive shadcn/ui component addition
|
||||
```
|
||||
@@ -1,11 +1,6 @@
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import { createJiti } from 'jiti';
|
||||
import { withPlausibleProxy } from 'next-plausible';
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
|
||||
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
|
||||
await jiti.import('./src/env');
|
||||
import { env } from './src/env.js';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = withPlausibleProxy({
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@types/react": "catalog:react19",
|
||||
"@types/react-dom": "catalog:react19",
|
||||
"eslint": "catalog:",
|
||||
"jiti": "^2.5.1",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
BIN
apps/next/public/misc/auth/gibs_auth_wide_header.png
Normal file
BIN
apps/next/public/misc/auth/gibs_auth_wide_header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
460
apps/next/src/app/(auth)/sign-in/page.tsx
Normal file
460
apps/next/src/app/(auth)/sign-in/page.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
'use client';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import Link from 'next/link';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ConvexError } from 'convex/values';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@gib/ui';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
GibsAuthSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@gib/backend/types';
|
||||
|
||||
const signInFormSchema = z.object({
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z.string().regex(PASSWORD_REGEX, {
|
||||
message: 'Incorrect password. Does not meet requirements.',
|
||||
}),
|
||||
});
|
||||
|
||||
const signUpFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(PASSWORD_MIN, {
|
||||
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||
})
|
||||
.max(PASSWORD_MAX, {
|
||||
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
|
||||
})
|
||||
.regex(/^\S+$/, {
|
||||
message: 'Password must not contain whitespace.',
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: 'Password must contain at least one digit.',
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'Password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[\p{P}\p{S}]/u, {
|
||||
message: 'Password must contain at least one symbol.',
|
||||
}),
|
||||
confirmPassword: z.string().min(PASSWORD_MIN, {
|
||||
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match!',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const verifyEmailFormSchema = z.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
});
|
||||
|
||||
const SignIn = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||
'signIn',
|
||||
);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
|
||||
resolver: zodResolver(signInFormSchema),
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||
resolver: zodResolver(verifyEmailFormSchema),
|
||||
defaultValues: { code },
|
||||
});
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
toast.error('Error signing in.');
|
||||
} finally {
|
||||
signInForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
formData.append('flow', flow);
|
||||
formData.append('name', values.name);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (values.confirmPassword !== values.password)
|
||||
throw new ConvexError('Passwords do not match.');
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('email-verification');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
toast.error('Error signing up.');
|
||||
} finally {
|
||||
signUpForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (
|
||||
values: z.infer<typeof verifyEmailFormSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('flow', flow);
|
||||
formData.append('email', email);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/'));
|
||||
} catch (error) {
|
||||
console.error('Error verifying email:', error);
|
||||
toast.error('Error verifying email.');
|
||||
} finally {
|
||||
verifyEmailForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (flow === 'email-verification') {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<CardContent>
|
||||
<div className='text-center mb-6'>
|
||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||
</div>
|
||||
<Form {...verifyEmailForm}>
|
||||
<form
|
||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSeparator />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your email.
|
||||
</FormDescription>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Verify Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='text-center mt-4'>
|
||||
<button
|
||||
onClick={() => setFlow('signUp')}
|
||||
className='text-sm text-muted-foreground hover:underline'
|
||||
>
|
||||
Back to Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<Tabs
|
||||
defaultValue={flow}
|
||||
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||
className='items-center'
|
||||
>
|
||||
<TabsList className='py-6'>
|
||||
<TabsTrigger
|
||||
value='signIn'
|
||||
className='p-6 text-2xl font-bold cursor-pointer'
|
||||
>
|
||||
Sign In
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='signUp'
|
||||
className='p-6 text-2xl font-bold cursor-pointer'
|
||||
>
|
||||
Sign Up
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='signIn'>
|
||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
||||
<CardContent>
|
||||
<Form {...signInForm}>
|
||||
<form
|
||||
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-xl'>Password</FormLabel>
|
||||
<Link href='/forgot-password'>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex justify-center'>
|
||||
<div
|
||||
className='flex flex-row items-center
|
||||
my-2.5 mx-auto justify-center w-1/4'
|
||||
>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='signUp'>
|
||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
||||
<CardContent>
|
||||
<Form {...signUpForm}>
|
||||
<form
|
||||
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Full Name'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Confirm Passsword
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Confirm your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex my-auto justify-center w-2/3'>
|
||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SignIn;
|
||||
@@ -1,34 +1,16 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { cn } from '@acme/ui';
|
||||
import { ThemeProvider, ThemeToggle } from '@acme/ui/theme';
|
||||
import { Toaster } from '@acme/ui/toast';
|
||||
|
||||
import { env } from '~/env';
|
||||
import { TRPCReactProvider } from '~/trpc/react';
|
||||
import '@/app/styles.css';
|
||||
|
||||
import '~/app/styles.css';
|
||||
import { ConvexClientProvider } from '@/components/providers';
|
||||
import { generateMetadata } from '@/lib/metadata';
|
||||
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
|
||||
import PlausibleProvider from 'next-plausible';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(
|
||||
env.VERCEL_ENV === 'production'
|
||||
? 'https://turbo.t3.gg'
|
||||
: 'http://localhost:3000',
|
||||
),
|
||||
title: 'Create T3 Turbo',
|
||||
description: 'Simple monorepo with shared backend for web & mobile apps',
|
||||
openGraph: {
|
||||
title: 'Create T3 Turbo',
|
||||
description: 'Simple monorepo with shared backend for web & mobile apps',
|
||||
url: 'https://create-t3-turbo.vercel.app',
|
||||
siteName: 'Create T3 Turbo',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@jullerino',
|
||||
creator: '@jullerino',
|
||||
},
|
||||
};
|
||||
import { ThemeProvider, Toaster } from '@gib/ui';
|
||||
|
||||
export const metadata: Metadata = generateMetadata();
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
@@ -46,24 +28,36 @@ const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
export default function RootLayout(props: { children: React.ReactNode }) {
|
||||
const RootLayout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
'bg-background text-foreground min-h-screen font-sans antialiased',
|
||||
geistSans.variable,
|
||||
geistMono.variable,
|
||||
)}
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<PlausibleProvider
|
||||
domain="convexmonorepo.gbrown.org"
|
||||
customDomain="https://plausible.gbrown.org"
|
||||
>
|
||||
<ThemeProvider>
|
||||
<TRPCReactProvider>{props.children}</TRPCReactProvider>
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
export default RootLayout;
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { HydrateClient, prefetch, trpc } from '~/trpc/server';
|
||||
import { AuthShowcase } from './_components/auth-showcase';
|
||||
import {
|
||||
CreatePostForm,
|
||||
PostCardSkeleton,
|
||||
PostList,
|
||||
} from './_components/posts';
|
||||
|
||||
export default function HomePage() {
|
||||
prefetch(trpc.post.all.queryOptions());
|
||||
'use client';
|
||||
|
||||
const Home = () => {
|
||||
return (
|
||||
<HydrateClient>
|
||||
<main className="container h-screen py-16">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
|
||||
Create <span className="text-primary">T3</span> Turbo
|
||||
</h1>
|
||||
<AuthShowcase />
|
||||
|
||||
<CreatePostForm />
|
||||
<div className="w-full max-w-2xl overflow-y-scroll">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
Hello!
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import '@acme/tailwind-config/theme';
|
||||
@import '@gib/tailwind-config/theme';
|
||||
|
||||
@source '../../../../packages/ui/src/*.{ts,tsx}';
|
||||
|
||||
|
||||
46
apps/next/src/components/layout/auth/buttons/gibs-auth.tsx
Normal file
46
apps/next/src/components/layout/auth/buttons/gibs-auth.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Image from 'next/image';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import type { buttonVariants } from '@gib/ui';
|
||||
import { Button } from '@gib/ui';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
interface Props {
|
||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
type?: 'signIn' | 'signUp';
|
||||
};
|
||||
|
||||
export const GibsAuthSignInButton = ({
|
||||
buttonProps,
|
||||
type = 'signIn',
|
||||
}: Props) => {
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => signIn('authentik')}
|
||||
className='text-lg font-semibold'
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='flex flex-col my-auto space-x-1'>
|
||||
<p>
|
||||
{
|
||||
type === 'signIn'
|
||||
? 'Sign In'
|
||||
: 'Sign Up'
|
||||
} with
|
||||
</p>
|
||||
<Image
|
||||
src={'/misc/auth/gibs_auth_wide_header.png'}
|
||||
className=''
|
||||
alt="Gib's Auth"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
1
apps/next/src/components/layout/auth/buttons/index.tsx
Normal file
1
apps/next/src/components/layout/auth/buttons/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { GibsAuthSignInButton } from './gibs-auth';
|
||||
16
apps/next/src/components/providers/ConvexClientProvider.tsx
Normal file
16
apps/next/src/components/providers/ConvexClientProvider.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { env } from '@/env';
|
||||
|
||||
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
export function ConvexClientProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ConvexAuthNextjsProvider client={convex}>
|
||||
{children}
|
||||
</ConvexAuthNextjsProvider>
|
||||
);
|
||||
}
|
||||
1
apps/next/src/components/providers/index.tsx
Normal file
1
apps/next/src/components/providers/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
129
apps/next/src/lib/metadata.ts
Normal file
129
apps/next/src/lib/metadata.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from 'next';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: {
|
||||
template: '%s | Convex Monorepo',
|
||||
default: 'Convex Monorepo',
|
||||
},
|
||||
description: 'A Convex Monorepo with Next.js & Expo',
|
||||
applicationName: 'Convex Monorepo',
|
||||
keywords:
|
||||
'Convex Monorepo,T3 Template, Nextjs, ' +
|
||||
'Tailwind, TypeScript, React, T3, Gib',
|
||||
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
|
||||
creator: 'Gib Brown',
|
||||
publisher: 'Gib Brown',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: false,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
noimageindex: false,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
|
||||
{
|
||||
url: '/favicon.ico',
|
||||
type: 'image/x-icon',
|
||||
sizes: 'any',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
],
|
||||
//shortcut: [
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
//],
|
||||
//apple: [
|
||||
//{
|
||||
//url: 'appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: 'appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
//],
|
||||
//other: [
|
||||
//{
|
||||
//rel: 'apple-touch-icon-precomposed',
|
||||
//url: '/appicon/icon-precomposed.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '180x180',
|
||||
//},
|
||||
//],
|
||||
},
|
||||
other: {
|
||||
...Sentry.getTraceData(),
|
||||
},
|
||||
//appleWebApp: {
|
||||
//title: 'Convex Monorepo',
|
||||
//statusBarStyle: 'black-translucent',
|
||||
//startupImage: [
|
||||
//'/icons/apple/splash-768x1004.png',
|
||||
//{
|
||||
//url: '/icons/apple/splash-1536x2008.png',
|
||||
//media: '(device-width: 768px) and (device-height: 1024px)',
|
||||
//},
|
||||
//],
|
||||
//},
|
||||
verification: {
|
||||
google: 'google',
|
||||
yandex: 'yandex',
|
||||
yahoo: 'yahoo',
|
||||
},
|
||||
category: 'technology',
|
||||
/*
|
||||
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,
|
||||
},
|
||||
},
|
||||
*/
|
||||
};
|
||||
};
|
||||
199
apps/next/src/lib/middleware/ban-sus-ips.ts
Normal file
199
apps/next/src/lib/middleware/ban-sus-ips.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
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>();
|
||||
|
||||
// Suspicious patterns that indicate malicious activity
|
||||
const MALICIOUS_PATTERNS = [
|
||||
// Your existing 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,
|
||||
|
||||
// New patterns from your logs
|
||||
/\/appliance\//i,
|
||||
/bomgar/i,
|
||||
/netburner-logo/i,
|
||||
/\/ui\/images\//i,
|
||||
/logon_merge/i,
|
||||
/logon_t\.gif/i,
|
||||
/login_top\.gif/i,
|
||||
/theme1\/images/i,
|
||||
/\.well-known\/acme-challenge\/.*\.jpg$/i,
|
||||
/\.well-known\/pki-validation\/.*\.jpg$/i,
|
||||
|
||||
// Path traversal and system file access patterns
|
||||
/\/etc\/passwd/i,
|
||||
/\/etc%2fpasswd/i,
|
||||
/\/etc%5cpasswd/i,
|
||||
/\/\/+etc/i,
|
||||
/\\\\+.*etc/i,
|
||||
/%2f%2f/i,
|
||||
/%5c%5c/i,
|
||||
/\/\/+/,
|
||||
/\\\\+/,
|
||||
/%00/i,
|
||||
/%23/i,
|
||||
|
||||
// Encoded path traversal attempts
|
||||
/%2e%2e/i,
|
||||
/%252e/i,
|
||||
/%c0%ae/i,
|
||||
/%c1%9c/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
|
||||
|
||||
// 404 rate limiting settings
|
||||
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
|
||||
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
|
||||
|
||||
const getClientIP = (request: NextRequest): string => {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const realIP = request.headers.get('x-real-ip');
|
||||
const cfConnectingIP = request.headers.get('cf-connecting-ip');
|
||||
|
||||
if (forwarded) return (forwarded.split(',')[0] ?? '').trim();
|
||||
if (realIP) return realIP;
|
||||
if (cfConnectingIP) return cfConnectingIP;
|
||||
return request.headers.get('host') ?? '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);
|
||||
ipAttempts.delete(ip);
|
||||
|
||||
setTimeout(() => {
|
||||
bannedIPs.delete(ip);
|
||||
}, BAN_DURATION);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const update404Attempts = (ip: string): boolean => {
|
||||
const now = Date.now();
|
||||
const attempts = ip404Attempts.get(ip);
|
||||
|
||||
if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) {
|
||||
ip404Attempts.set(ip, { count: 1, lastAttempt: now });
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
attempts.lastAttempt = now;
|
||||
|
||||
if (attempts.count > MAX_404_ATTEMPTS) {
|
||||
bannedIPs.add(ip);
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
|
||||
const { pathname } = request.nextUrl;
|
||||
const method = request.method;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
// Check if IP is already banned
|
||||
if (bannedIPs.has(ip)) {
|
||||
return new NextResponse('Access denied.', { status: 403 });
|
||||
}
|
||||
|
||||
const isSuspiciousPath = isPathSuspicious(pathname);
|
||||
const isSuspiciousMethod = isMethodSuspicious(method);
|
||||
|
||||
// Handle suspicious activity
|
||||
if (isSuspiciousPath || isSuspiciousMethod) {
|
||||
const shouldBan = updateIPAttempts(ip);
|
||||
|
||||
if (shouldBan) {
|
||||
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
|
||||
return new NextResponse('Access denied - IP banned. Please fuck off.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Call this function when you detect a 404 response
|
||||
export const handle404Response = (
|
||||
request: NextRequest,
|
||||
): NextResponse | null => {
|
||||
const ip = getClientIP(request);
|
||||
|
||||
if (bannedIPs.has(ip)) {
|
||||
return new NextResponse('Access denied.', { status: 403 });
|
||||
}
|
||||
|
||||
const shouldBan = update404Attempts(ip);
|
||||
|
||||
if (shouldBan) {
|
||||
return new NextResponse('Access denied - IP banned for excessive 404s.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
34
apps/next/src/middleware.ts
Normal file
34
apps/next/src/middleware.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
convexAuthNextjsMiddleware,
|
||||
createRouteMatcher,
|
||||
nextjsMiddlewareRedirect,
|
||||
} from '@convex-dev/auth/nextjs/server';
|
||||
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
|
||||
|
||||
const isSignInPage = createRouteMatcher(['/sign-in']);
|
||||
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
||||
|
||||
export default convexAuthNextjsMiddleware(
|
||||
async (request, { convexAuth }) => {
|
||||
const banResponse = banSuspiciousIPs(request);
|
||||
if (banResponse) return banResponse;
|
||||
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/');
|
||||
}
|
||||
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
|
||||
return nextjsMiddlewareRedirect(request, '/sign-in');
|
||||
}
|
||||
},
|
||||
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
|
||||
);
|
||||
|
||||
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).*)',
|
||||
'/',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
};
|
||||
14
bun.lock
14
bun.lock
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "convex-turbo",
|
||||
"devDependencies": {
|
||||
"@gib/prettier-config": "workspace:",
|
||||
"@turbo/gen": "^2.7.3",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"prettier": "catalog:",
|
||||
"turbo": "^2.7.3",
|
||||
@@ -91,8 +91,8 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:react19",
|
||||
"@types/react-dom": "catalog:react19",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"eslint": "catalog:",
|
||||
"jiti": "^2.5.1",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -240,7 +240,7 @@
|
||||
"react19": {
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react-dom": "~19.1.0",
|
||||
"react": "^19.1.4",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
},
|
||||
},
|
||||
@@ -1605,7 +1605,7 @@
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="],
|
||||
|
||||
"basic-ftp": ["basic-ftp@5.0.5", "", {}, "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="],
|
||||
|
||||
@@ -2689,7 +2689,7 @@
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
"react": ["react@19.1.4", "", {}, "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw=="],
|
||||
|
||||
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
|
||||
|
||||
@@ -3397,6 +3397,8 @@
|
||||
|
||||
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
|
||||
|
||||
"builtins/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"chalk-template/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -3623,6 +3625,8 @@
|
||||
|
||||
"update-browserslist-db/picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"usesend-js/react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
"webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"webpack/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"react19": {
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react-dom": "~19.1.0",
|
||||
"react": "^19.1.4",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4"
|
||||
}
|
||||
},
|
||||
@@ -56,6 +56,7 @@
|
||||
"devDependencies": {
|
||||
"@gib/prettier-config": "workspace:",
|
||||
"@turbo/gen": "^2.7.3",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"prettier": "catalog:",
|
||||
"turbo": "^2.7.3",
|
||||
|
||||
32
packages/backend/convex/_generated/api.d.ts
vendored
32
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,15 +8,18 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as custom_auth_index from "../custom/auth/index.js";
|
||||
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
||||
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as utils from "../utils.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from 'convex/server';
|
||||
|
||||
import type * as notes from '../notes.js';
|
||||
import type * as openai from '../openai.js';
|
||||
import type * as utils from '../utils.js';
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
@@ -27,15 +30,22 @@ import type * as utils from '../utils.js';
|
||||
* ```
|
||||
*/
|
||||
declare const fullApi: ApiFromModules<{
|
||||
notes: typeof notes;
|
||||
openai: typeof openai;
|
||||
auth: typeof auth;
|
||||
"custom/auth/index": typeof custom_auth_index;
|
||||
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
||||
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
||||
http: typeof http;
|
||||
utils: typeof utils;
|
||||
}>;
|
||||
declare const fullApiWithMounts: typeof fullApi;
|
||||
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, 'public'>
|
||||
typeof fullApiWithMounts,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, 'internal'>
|
||||
typeof fullApiWithMounts,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi } from 'convex/server';
|
||||
import { anyApi, componentsGeneric } from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
@@ -20,3 +20,4 @@ import { anyApi } from 'convex/server';
|
||||
*/
|
||||
export const api = anyApi;
|
||||
export const internal = anyApi;
|
||||
export const components = componentsGeneric();
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
import type {
|
||||
DataModelFromSchemaDefinition,
|
||||
DocumentByName,
|
||||
SystemTableNames,
|
||||
TableNamesInDataModel,
|
||||
} from 'convex/server';
|
||||
import type { GenericId } from 'convex/values';
|
||||
|
||||
import schema from '../schema.js';
|
||||
SystemTableNames,
|
||||
} from "convex/server";
|
||||
import type { GenericId } from "convex/values";
|
||||
import schema from "../schema.js";
|
||||
|
||||
/**
|
||||
* The names of all of your Convex tables.
|
||||
|
||||
32
packages/backend/convex/_generated/server.d.ts
vendored
32
packages/backend/convex/_generated/server.d.ts
vendored
@@ -10,17 +10,23 @@
|
||||
|
||||
import {
|
||||
ActionBuilder,
|
||||
GenericActionCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
AnyComponents,
|
||||
HttpActionBuilder,
|
||||
MutationBuilder,
|
||||
QueryBuilder,
|
||||
} from 'convex/server';
|
||||
GenericActionCtx,
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
|
||||
import type { DataModel } from './dataModel.js';
|
||||
type GenericCtx =
|
||||
| GenericActionCtx<DataModel>
|
||||
| GenericMutationCtx<DataModel>
|
||||
| GenericQueryCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
@@ -30,7 +36,7 @@ import type { DataModel } from './dataModel.js';
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const query: QueryBuilder<DataModel, 'public'>;
|
||||
export declare const query: QueryBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -40,7 +46,7 @@ export declare const query: QueryBuilder<DataModel, 'public'>;
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
@@ -50,7 +56,7 @@ export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -60,7 +66,7 @@ export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
@@ -73,7 +79,7 @@ export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const action: ActionBuilder<DataModel, 'public'>;
|
||||
export declare const action: ActionBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -81,7 +87,7 @@ export declare const action: ActionBuilder<DataModel, 'public'>;
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
|
||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
import {
|
||||
actionGeneric,
|
||||
httpActionGeneric,
|
||||
queryGeneric,
|
||||
mutationGeneric,
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
mutationGeneric,
|
||||
queryGeneric,
|
||||
} from 'convex/server';
|
||||
componentsGeneric,
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
"description": "Convex Backend for Monorepo",
|
||||
"author": "Gib",
|
||||
"license": "MIT",
|
||||
"exports": {},
|
||||
"exports": {
|
||||
"./types" : "./types/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "convex dev",
|
||||
"dev:tunnel": "convex dev",
|
||||
"setup": "convex dev --until-success",
|
||||
"dev": "bun with-env convex dev",
|
||||
"dev:tunnel": "bun with-env convex dev",
|
||||
"setup": "bun with-env convex dev --until-success",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
|
||||
17
packages/backend/scripts/generateKeys.mjs
Executable file
17
packages/backend/scripts/generateKeys.mjs
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
|
||||
|
||||
const keys = await generateKeyPair("RS256", {
|
||||
extractable: true,
|
||||
});
|
||||
const privateKey = await exportPKCS8(keys.privateKey);
|
||||
const publicKey = await exportJWK(keys.publicKey);
|
||||
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
|
||||
|
||||
process.stdout.write(
|
||||
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`,
|
||||
);
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(`JWKS=${jwks}`);
|
||||
process.stdout.write("\n");
|
||||
4
packages/backend/types/auth.ts
Normal file
4
packages/backend/types/auth.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PASSWORD_MIN = 8;
|
||||
export const PASSWORD_MAX = 100;
|
||||
export const PASSWORD_REGEX =
|
||||
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
|
||||
5
packages/backend/types/index.ts
Normal file
5
packages/backend/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PASSWORD_MIN,
|
||||
PASSWORD_MAX,
|
||||
PASSWORD_REGEX,
|
||||
} from './auth';
|
||||
Reference in New Issue
Block a user