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 { withSentryConfig } from '@sentry/nextjs';
|
||||||
import { createJiti } from 'jiti';
|
|
||||||
import { withPlausibleProxy } from 'next-plausible';
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
import { env } from './src/env.js';
|
||||||
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');
|
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = withPlausibleProxy({
|
const config = withPlausibleProxy({
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
"@types/react-dom": "catalog:react19",
|
"@types/react-dom": "catalog:react19",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^2.5.1",
|
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tw-animate-css": "^1.4.0",
|
"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 type { Metadata, Viewport } from 'next';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
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 '@/app/styles.css';
|
||||||
import { TRPCReactProvider } from '~/trpc/react';
|
|
||||||
|
|
||||||
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 = {
|
import { ThemeProvider, Toaster } from '@gib/ui';
|
||||||
metadataBase: new URL(
|
|
||||||
env.VERCEL_ENV === 'production'
|
export const metadata: Metadata = generateMetadata();
|
||||||
? '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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
@@ -46,24 +28,36 @@ const geistMono = Geist_Mono({
|
|||||||
variable: '--font-geist-mono',
|
variable: '--font-geist-mono',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RootLayout(props: { children: React.ReactNode }) {
|
const RootLayout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<ConvexAuthNextjsServerProvider>
|
||||||
<body
|
<PlausibleProvider
|
||||||
className={cn(
|
domain="convexmonorepo.gbrown.org"
|
||||||
'bg-background text-foreground min-h-screen font-sans antialiased',
|
customDomain="https://plausible.gbrown.org"
|
||||||
geistSans.variable,
|
|
||||||
geistMono.variable,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ThemeProvider>
|
<html lang="en">
|
||||||
<TRPCReactProvider>{props.children}</TRPCReactProvider>
|
<body
|
||||||
<div className="absolute right-4 bottom-4">
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
<ThemeToggle />
|
>
|
||||||
</div>
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<ConvexClientProvider>
|
||||||
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
</ConvexClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
</PlausibleProvider>
|
||||||
|
</ConvexAuthNextjsServerProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
export default RootLayout;
|
||||||
|
|||||||
@@ -1,41 +1,11 @@
|
|||||||
import { Suspense } from 'react';
|
'use client';
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
return (
|
return (
|
||||||
<HydrateClient>
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<main className="container h-screen py-16">
|
Hello!
|
||||||
<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>
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<PostList />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</HydrateClient>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import '@acme/tailwind-config/theme';
|
@import '@gib/tailwind-config/theme';
|
||||||
|
|
||||||
@source '../../../../packages/ui/src/*.{ts,tsx}';
|
@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,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "convex-turbo",
|
"name": "convex-turbo",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gib/prettier-config": "workspace:",
|
"@gib/prettier-config": "workspace:",
|
||||||
"@turbo/gen": "^2.7.3",
|
"@turbo/gen": "^2.7.3",
|
||||||
|
"baseline-browser-mapping": "^2.9.14",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "^10.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"turbo": "^2.7.3",
|
"turbo": "^2.7.3",
|
||||||
@@ -91,8 +91,8 @@
|
|||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
"@types/react-dom": "catalog:react19",
|
"@types/react-dom": "catalog:react19",
|
||||||
|
"baseline-browser-mapping": "^2.9.14",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^2.5.1",
|
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
"react19": {
|
"react19": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"@types/react-dom": "~19.1.0",
|
"@types/react-dom": "~19.1.0",
|
||||||
"react": "^19.1.4",
|
"react": "19.1.4",
|
||||||
"react-dom": "19.1.4",
|
"react-dom": "19.1.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1605,7 +1605,7 @@
|
|||||||
|
|
||||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"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": {
|
"react19": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"@types/react-dom": "~19.1.0",
|
"@types/react-dom": "~19.1.0",
|
||||||
"react": "^19.1.4",
|
"react": "19.1.4",
|
||||||
"react-dom": "19.1.4"
|
"react-dom": "19.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gib/prettier-config": "workspace:",
|
"@gib/prettier-config": "workspace:",
|
||||||
"@turbo/gen": "^2.7.3",
|
"@turbo/gen": "^2.7.3",
|
||||||
|
"baseline-browser-mapping": "^2.9.14",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "^10.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"turbo": "^2.7.3",
|
"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
|
* @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 {
|
import type {
|
||||||
ApiFromModules,
|
ApiFromModules,
|
||||||
FilterApi,
|
FilterApi,
|
||||||
FunctionReference,
|
FunctionReference,
|
||||||
} from 'convex/server';
|
} from "convex/server";
|
||||||
|
|
||||||
import type * as notes from '../notes.js';
|
|
||||||
import type * as openai from '../openai.js';
|
|
||||||
import type * as utils from '../utils.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* 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<{
|
declare const fullApi: ApiFromModules<{
|
||||||
notes: typeof notes;
|
auth: typeof auth;
|
||||||
openai: typeof openai;
|
"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;
|
utils: typeof utils;
|
||||||
}>;
|
}>;
|
||||||
|
declare const fullApiWithMounts: typeof fullApi;
|
||||||
|
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
typeof fullApi,
|
typeof fullApiWithMounts,
|
||||||
FunctionReference<any, 'public'>
|
FunctionReference<any, "public">
|
||||||
>;
|
>;
|
||||||
export declare const internal: FilterApi<
|
export declare const internal: FilterApi<
|
||||||
typeof fullApi,
|
typeof fullApiWithMounts,
|
||||||
FunctionReference<any, 'internal'>
|
FunctionReference<any, "internal">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export declare const components: {};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { anyApi } from 'convex/server';
|
import { anyApi, componentsGeneric } from "convex/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* 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 api = anyApi;
|
||||||
export const internal = anyApi;
|
export const internal = anyApi;
|
||||||
|
export const components = componentsGeneric();
|
||||||
|
|||||||
@@ -11,12 +11,11 @@
|
|||||||
import type {
|
import type {
|
||||||
DataModelFromSchemaDefinition,
|
DataModelFromSchemaDefinition,
|
||||||
DocumentByName,
|
DocumentByName,
|
||||||
SystemTableNames,
|
|
||||||
TableNamesInDataModel,
|
TableNamesInDataModel,
|
||||||
} from 'convex/server';
|
SystemTableNames,
|
||||||
import type { GenericId } from 'convex/values';
|
} from "convex/server";
|
||||||
|
import type { GenericId } from "convex/values";
|
||||||
import schema from '../schema.js';
|
import schema from "../schema.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The names of all of your Convex tables.
|
* 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 {
|
import {
|
||||||
ActionBuilder,
|
ActionBuilder,
|
||||||
GenericActionCtx,
|
AnyComponents,
|
||||||
GenericDatabaseReader,
|
|
||||||
GenericDatabaseWriter,
|
|
||||||
GenericMutationCtx,
|
|
||||||
GenericQueryCtx,
|
|
||||||
HttpActionBuilder,
|
HttpActionBuilder,
|
||||||
MutationBuilder,
|
MutationBuilder,
|
||||||
QueryBuilder,
|
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.
|
* 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.
|
* @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.
|
* @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).
|
* 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.
|
* @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.
|
* @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.
|
* 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.
|
* @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.
|
* @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).
|
* 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.
|
* @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.
|
* @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.
|
* 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.
|
* @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.
|
* @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).
|
* 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.
|
* @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.
|
* @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.
|
* Define an HTTP action.
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
import {
|
import {
|
||||||
actionGeneric,
|
actionGeneric,
|
||||||
httpActionGeneric,
|
httpActionGeneric,
|
||||||
|
queryGeneric,
|
||||||
|
mutationGeneric,
|
||||||
internalActionGeneric,
|
internalActionGeneric,
|
||||||
internalMutationGeneric,
|
internalMutationGeneric,
|
||||||
internalQueryGeneric,
|
internalQueryGeneric,
|
||||||
mutationGeneric,
|
componentsGeneric,
|
||||||
queryGeneric,
|
} from "convex/server";
|
||||||
} from 'convex/server';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
|
|||||||
@@ -6,15 +6,18 @@
|
|||||||
"description": "Convex Backend for Monorepo",
|
"description": "Convex Backend for Monorepo",
|
||||||
"author": "Gib",
|
"author": "Gib",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {},
|
"exports": {
|
||||||
|
"./types" : "./types/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "convex dev",
|
"dev": "bun with-env convex dev",
|
||||||
"dev:tunnel": "convex dev",
|
"dev:tunnel": "bun with-env convex dev",
|
||||||
"setup": "convex dev --until-success",
|
"setup": "bun with-env convex dev --until-success",
|
||||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@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