add google auth signin

This commit is contained in:
zramsay
2025-03-11 12:01:21 -04:00
parent c0f908c4fb
commit e0e3f79f38
20 changed files with 752 additions and 415 deletions
+27
View File
@@ -0,0 +1,27 @@
# Wildlife Project Development Guidelines
## Build/Lint/Test Commands
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run lint` - Run ESLint
## Code Style Guidelines
- **Imports**: Group imports by type (React, components, services, config)
- **Types**: Use TypeScript interfaces for data structures, React.FC for components
- **React Components**: Use functional components with explicit return types
- **Error Handling**: Use try/catch with detailed error logging in services
- **Naming Conventions**:
- PascalCase for components (ImageAnalysisCard.tsx)
- camelCase for functions, variables, and file names (googleVisionService.ts)
- Use descriptive names for functions and variables
- **Services**: Keep service files focused on a single responsibility
- **State Management**: Use React hooks for local state
- **Styling**: Use Tailwind CSS with theme configurations from appConfig
## Project Structure
- `/src/app` - Next.js pages and API routes
- `/src/components` - Reusable UI components
- `/src/services` - Business logic and API services
- `/src/config` - Application configuration
- `/src/types` - TypeScript type definitions
+55
View File
@@ -32,3 +32,58 @@ Receive links to custom portals and are invited to add data by uploading photogr
### Data consumers
Query a data set from any custom portal
## Social Login Setup
This application supports social login with Google and GitHub. To set up social login, follow these steps:
1. **Set up environment variables**:
Update the following variables in your `.env.local` file:
```
# Authentication
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-nextauth-secret-key-at-least-32-chars
# OAuth Providers
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
```
2. **Create OAuth applications**:
**For Google**:
- Go to [Google Cloud Console](https://console.cloud.google.com/)
- Create a new project or select an existing one
- Navigate to "APIs & Services" > "Credentials"
- Click "Create Credentials" > "OAuth client ID"
- Select "Web application" as the application type
- Add your app's URL to the authorized JavaScript origins (e.g., `http://localhost:3000`)
- Add your app's callback URL to the authorized redirect URIs (e.g., `http://localhost:3000/api/auth/callback/google`)
- Copy the Client ID and Client Secret to your `.env.local` file
**For GitHub**:
- Go to [GitHub Developer Settings](https://github.com/settings/developers)
- Click on "New OAuth App"
- Fill in the application details
- Set the Authorization callback URL to your app's callback URL (e.g., `http://localhost:3000/api/auth/callback/github`)
- Copy the Client ID and Client Secret to your `.env.local` file
3. **Generate a secure NEXTAUTH_SECRET**:
Run the following command to generate a secure secret:
```bash
openssl rand -base64 32
```
Copy the output to your `.env.local` file as the NEXTAUTH_SECRET value.
4. **Install required packages**:
```bash
npm install next-auth@4.22.1
```
5. **Restart your application**:
```bash
npm run dev
```
+119 -340
View File
@@ -23,6 +23,7 @@
"lucide-react": "^0.469.0",
"multer": "^1.4.5-lts.1",
"next": "^15.1.4",
"next-auth": "^5.0.0-beta.25",
"next-connect": "^1.0.0",
"openai": "^4.77.0",
"react": "^18",
@@ -55,6 +56,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.37.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz",
"integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"@types/cookie": "0.6.0",
"cookie": "0.7.1",
"jose": "^5.9.3",
"oauth4webapi": "^3.0.0",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
@@ -66,16 +97,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
"integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
@@ -311,28 +332,6 @@
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
@@ -349,307 +348,6 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -798,7 +496,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -814,7 +511,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -830,7 +526,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -846,7 +541,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -862,7 +556,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -878,7 +571,6 @@
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -894,7 +586,6 @@
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -983,6 +674,14 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1320,6 +1019,11 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/exif-js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@types/exif-js/-/exif-js-2.3.1.tgz",
@@ -2539,6 +2243,14 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -4961,6 +4673,14 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5409,6 +5129,32 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.25",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz",
"integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==",
"dependencies": {
"@auth/core": "0.37.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0-0",
"nodemailer": "^6.6.5",
"react": "^18.2.0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-connect": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/next-connect/-/next-connect-1.0.0.tgz",
@@ -5494,6 +5240,14 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.3.1.tgz",
"integrity": "sha512-ZwX7UqYrP3Lr+Glhca3a1/nF2jqf7VVyJfhGuW5JtrfDUxt0u+IoBPzFjZ2dd7PJGkdM6CFPVVYzuDYKHv101A==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5991,6 +5745,26 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6000,6 +5774,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+1
View File
@@ -24,6 +24,7 @@
"lucide-react": "^0.469.0",
"multer": "^1.4.5-lts.1",
"next": "^15.1.4",
"next-auth": "^5.0.0-beta.25",
"next-connect": "^1.0.0",
"openai": "^4.77.0",
"react": "^18",
+12 -15
View File
@@ -25,13 +25,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const formData = await req.formData()
const imageFile = formData.get('image')
// More explicit type checking and conversion
const file = imageFile instanceof File
? imageFile
: (Array.isArray(imageFile) ? imageFile[0] : null)
// Validate image file
if (!file) {
// For server-side, check if we have a valid Blob-like object
// In Node.js environment, Formidable gives us a Blob-like interface
if (!imageFile || !(typeof imageFile === 'object' && 'arrayBuffer' in imageFile)) {
console.error('Invalid file upload:', {
imageFile: imageFile ? 'Exists' : 'Not found',
type: imageFile ? typeof imageFile : 'undefined'
@@ -42,16 +38,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
{ status: 400 }
)
}
// Log file details
// Log file details (some properties may be different from browser File objects)
console.log('Uploaded file details:', {
name: file.name,
type: file.type,
size: file.size
name: 'name' in imageFile ? imageFile.name : 'unknown',
type: 'type' in imageFile ? imageFile.type : 'unknown',
size: 'size' in imageFile ? imageFile.size : 'unknown'
})
// Convert image to buffer
const arrayBuffer = await file.arrayBuffer()
// Convert image to buffer using arrayBuffer method
const arrayBuffer = await imageFile.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Log buffer details
@@ -91,11 +87,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
// Background processing for animal images
if (visionResult.isAnimal) {
const fileName = 'name' in imageFile ? imageFile.name : 'unknown-image.jpg'
processAnimalImage(
buffer,
visionResult.description,
visionResult.rawResponse,
file.name
fileName
).catch(console.error)
}
+48
View File
@@ -0,0 +1,48 @@
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
export const GET = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
}),
],
pages: {
signIn: '/auth/signin',
signOut: '/auth/signout',
error: '/auth/error',
},
callbacks: {
async session({ session, token }) {
// Add user info to the session
if (session.user && token.sub) {
session.user.id = token.sub;
}
return session;
},
},
}).handlers.GET;
export const POST = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
}),
],
pages: {
signIn: '/auth/signin',
signOut: '/auth/signout',
error: '/auth/error',
},
callbacks: {
async session({ session, token }) {
// Add user info to the session
if (session.user && token.sub) {
session.user.id = token.sub;
}
return session;
},
},
}).handlers.POST;
+67
View File
@@ -0,0 +1,67 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
const ErrorContent = () => {
const searchParams = useSearchParams();
const error = searchParams.get('error');
let errorMessage = 'An unknown error occurred during authentication.';
if (error === 'OAuthSignin') errorMessage = 'Error during OAuth sign-in process.';
if (error === 'OAuthCallback') errorMessage = 'Error during OAuth callback.';
if (error === 'OAuthCreateAccount') errorMessage = 'Error creating OAuth user.';
if (error === 'EmailCreateAccount') errorMessage = 'Error creating email user.';
if (error === 'Callback') errorMessage = 'Error during callback handling.';
if (error === 'OAuthAccountNotLinked') errorMessage = 'Email already used with a different provider.';
if (error === 'EmailSignin') errorMessage = 'Error sending email sign-in link.';
if (error === 'CredentialsSignin') errorMessage = 'Sign in failed. Check your credentials.';
if (error === 'SessionRequired') errorMessage = 'You must be signed in to access this page.';
if (error === 'Default') errorMessage = 'An unknown error occurred.';
return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center items-center p-4">
<div className="w-full max-w-md bg-gray-800 rounded-lg shadow-lg p-6">
<h1 className="text-2xl font-bold text-red-400 mb-6 text-center">Authentication Error</h1>
<div className="bg-red-900/20 border border-red-700 rounded-md p-4 mb-6">
<p className="text-red-200 text-center">{errorMessage}</p>
</div>
<div className="flex justify-center space-x-4">
<Link
href="/auth/signin"
className="bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
Try Again
</Link>
<Link
href="/"
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
Return Home
</Link>
</div>
</div>
</div>
);
};
const AuthErrorPage = () => {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-900 flex flex-col justify-center items-center p-4">
<div className="w-full max-w-md bg-gray-800 rounded-lg shadow-lg p-6">
<h1 className="text-2xl font-bold text-red-400 mb-6 text-center">Loading...</h1>
</div>
</div>
}>
<ErrorContent />
</Suspense>
);
};
export default AuthErrorPage;
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { signIn } from 'next-auth/react';
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
const SignInPage = () => {
const router = useRouter();
const handleGoogleSignIn = useCallback(() => {
console.log('Signing in with Google...');
signIn('google', { callbackUrl: '/' });
}, []);
const handleCancel = useCallback(() => {
router.push('/');
}, [router]);
return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center items-center p-4">
<div className="w-full max-w-md bg-gray-800 rounded-lg shadow-lg p-6">
<h1 className="text-2xl font-bold text-emerald-400 mb-6 text-center">Sign In to Ranger</h1>
<div className="space-y-4">
<button
onClick={handleGoogleSignIn}
className="w-full flex items-center justify-center space-x-2 bg-white hover:bg-gray-100 text-gray-800 font-medium py-3 px-4 rounded-md transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z"
/>
</svg>
<span>Continue with Google</span>
</button>
</div>
<div className="mt-6 text-center">
<button
onClick={handleCancel}
className="text-emerald-300 hover:text-emerald-200 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
);
};
export default SignInPage;
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
const SignOutPage = () => {
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleSignOut = useCallback(() => {
setIsSigningOut(true);
signOut({ callbackUrl: '/' });
}, []);
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// Auto redirect after 5 seconds
useEffect(() => {
const timer = setTimeout(() => {
router.push('/');
}, 5000);
return () => clearTimeout(timer);
}, [router]);
return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center items-center p-4">
<div className="w-full max-w-md bg-gray-800 rounded-lg shadow-lg p-6">
<h1 className="text-2xl font-bold text-emerald-400 mb-6 text-center">Sign Out</h1>
<p className="text-gray-300 text-center mb-6">
Are you sure you want to sign out?
</p>
<div className="flex justify-center space-x-4">
<button
onClick={handleSignOut}
disabled={isSigningOut}
className={`bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4 rounded-md transition-colors ${
isSigningOut ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{isSigningOut ? 'Signing Out...' : 'Sign Out'}
</button>
<button
onClick={handleCancel}
disabled={isSigningOut}
className={`bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-md transition-colors ${
isSigningOut ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
Cancel
</button>
</div>
</div>
</div>
);
};
export default SignOutPage;
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function DebugPage() {
const { data: session, status } = useSession();
const [authData, setAuthData] = useState<any>(null);
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch('/api/auth/session');
const data = await res.json();
setAuthData(data);
} catch (error) {
console.error('Error fetching auth session', error);
}
};
checkAuth();
}, []);
return (
<div className="min-h-screen bg-gray-900 p-8">
<div className="max-w-4xl mx-auto bg-gray-800 rounded-lg p-6 text-white">
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug Page</h1>
<div className="grid grid-cols-1 gap-6">
<div className="bg-gray-700 p-4 rounded-lg">
<h2 className="text-xl font-bold text-emerald-300 mb-2">Session Status</h2>
<p>Status: <span className="text-emerald-200">{status}</span></p>
<pre className="bg-gray-900 p-4 rounded mt-2 overflow-auto max-h-60">
{JSON.stringify(session, null, 2)}
</pre>
</div>
<div className="bg-gray-700 p-4 rounded-lg">
<h2 className="text-xl font-bold text-emerald-300 mb-2">Auth API Response</h2>
<pre className="bg-gray-900 p-4 rounded mt-2 overflow-auto max-h-60">
{JSON.stringify(authData, null, 2)}
</pre>
</div>
<div className="bg-gray-700 p-4 rounded-lg flex flex-col space-y-4">
<h2 className="text-xl font-bold text-emerald-300 mb-2">Auth Actions</h2>
<div className="flex flex-wrap gap-4">
<button
onClick={() => signIn('google', { callbackUrl: '/' })}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded"
>
Sign in with Google
</button>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded"
>
Sign Out
</button>
<Link href="/" className="px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded">
Back to Home
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
+6 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import SessionProvider from '../components/SessionProvider'
const inter = Inter({ subsets: ['latin'] })
@@ -16,7 +17,11 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
)
}
+9
View File
@@ -45,6 +45,15 @@ const Page: React.FC = (): React.ReactElement => {
Document sightings of {APP_CONFIG.description} in {APP_CONFIG.location}
</p>
<div className="mb-8 p-4 bg-emerald-900/30 rounded-lg inline-block">
<a
href="/auth/signin"
className="text-white bg-emerald-600 hover:bg-emerald-700 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
Sign in to get started
</a>
</div>
</div>
{/* Single Analysis Card */}
+20 -5
View File
@@ -81,11 +81,26 @@ export default function AnimalsPage() {
className="bg-emerald-900/20 rounded-xl p-6 border border-emerald-800/50 hover:border-emerald-700/50 transition-colors"
>
<div className="aspect-video rounded-lg overflow-hidden mb-4 bg-emerald-900/50">
<img
src={record.attributes?.imageUrl}
alt={`Wildlife sighting`}
className="w-full h-full object-cover"
/>
{record.attributes?.imageUrl ? (
<>
<div className="absolute top-0 right-0 bg-black/70 text-xs text-white p-1 z-10">
URL: {record.attributes.imageUrl.substring(0, 30)}...
</div>
<img
src={record.attributes.imageUrl}
alt={`Wildlife sighting of ${record.attributes.mainObject}`}
className="w-full h-full object-cover"
onError={(e) => {
console.error(`Image load error for: ${record.attributes.imageUrl}`);
e.currentTarget.src = 'https://placehold.co/600x400?text=Image+Error';
}}
/>
</>
) : (
<div className="flex items-center justify-center h-full bg-emerald-900/30 text-emerald-200">
No image available
</div>
)}
</div>
<h3 className="text-xl font-semibold text-emerald-300 capitalize mb-2">
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { signIn, signOut, useSession } from 'next-auth/react';
import Link from 'next/link';
import { useCallback } from 'react';
const AuthButton = () => {
const { data: session, status } = useSession();
const isLoading = status === 'loading';
const handleSignIn = useCallback(() => {
signIn('google', { callbackUrl: '/' });
}, []);
const handleSignOut = useCallback(() => {
signOut({ callbackUrl: '/' });
}, []);
if (isLoading) {
return (
<button className="text-sm text-emerald-200/70 bg-transparent" disabled>
Loading...
</button>
);
}
if (session?.user) {
return (
<div className="flex items-center space-x-3">
{session.user.image && (
<img
src={session.user.image}
alt={session.user.name || 'User'}
className="w-8 h-8 rounded-full"
/>
)}
<div className="flex flex-col">
<span className="text-sm font-medium text-emerald-200">
{session.user.name}
</span>
<button
onClick={handleSignOut}
className="text-xs text-emerald-200/70 hover:text-emerald-200 text-left"
>
Sign out
</button>
</div>
</div>
);
}
return (
<button
onClick={handleSignIn}
className="text-sm text-emerald-200/70 hover:text-emerald-200 bg-transparent"
>
Sign in
</button>
);
};
export default AuthButton;
+28 -19
View File
@@ -3,6 +3,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import AuthButton from './AuthButton'
const Navigation = () => {
const pathname = usePathname()
@@ -10,28 +11,36 @@ const Navigation = () => {
const links = [
{ href: '/', label: 'Home' },
{ href: '/sightings', label: 'Sightings' },
{ href: '/about', label: 'About' }
{ href: '/about', label: 'About' },
{ href: '/auth/signin', label: 'Sign In' },
{ href: '/debug', label: 'Debug' },
]
return (
<nav className="absolute left-4 top-8">
<ul className="flex space-x-6">
{links.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
className={`text-sm transition-colors duration-200 ${
pathname === href
? 'text-emerald-400'
: 'text-emerald-200/70 hover:text-emerald-200'
}`}
>
{label}
</Link>
</li>
))}
</ul>
</nav>
<div className="absolute w-full px-4 top-8 flex justify-between items-center bg-gray-900/50 py-2 rounded-lg">
<nav>
<ul className="flex space-x-6">
{links.map(({ href, label }) => (
<li key={href}>
<Link
href={href}
className={`text-sm transition-colors duration-200 ${
pathname === href
? 'text-emerald-400 font-bold'
: 'text-emerald-200/70 hover:text-emerald-200'
}`}
>
{label}
</Link>
</li>
))}
</ul>
</nav>
<div className="mr-4 border border-emerald-400/30 px-3 py-1 rounded">
<AuthButton />
</div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
'use client';
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';
interface SessionProviderProps {
children: ReactNode;
}
const SessionProvider = ({ children }: SessionProviderProps) => {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
};
export default SessionProvider;
+11 -1
View File
@@ -101,7 +101,17 @@ export async function fetchAnimalRecords(portalName: string): Promise<AnimalReco
record.attributes.portalName === process.env.NEXT_PUBLIC_PORTAL_NAME
);
console.log('Processed animal records:', records);
// Log more detailed information about each image URL
console.log('Processed animal records count:', records.length);
if (records.length > 0) {
console.log('Image URLs from records:');
records.forEach((record, index) => {
console.log(`Record ${index + 1} image URL: ${record.attributes.imageUrl}`);
});
} else {
console.log('No records found with valid image URLs');
}
return records;
} catch (error) {
+59 -33
View File
@@ -1,4 +1,6 @@
// src/services/pinataService.ts
import FormData from 'form-data';
import axios from 'axios';
interface PinataResponse {
IpfsHash: string
@@ -15,53 +17,77 @@ export async function uploadToIpfs(imageBuffer: Buffer, filename: string): Promi
throw new Error('Pinata configuration missing')
}
// Use native FormData creation
const formData = new FormData()
// Use Node.js form-data library for server-side
const formData = new FormData();
// Create file from buffer
// Use File instead of Blob for better compatibility
const file = new File([imageBuffer], filename, { type: 'image/jpeg' })
formData.append('file', file, filename)
// In Node.js, we can append buffer directly with filename
formData.append('file', imageBuffer, {
filename,
contentType: 'image/jpeg'
});
// Add metadata
const pinataMetadata = JSON.stringify({
name: filename,
})
formData.append('pinataMetadata', pinataMetadata)
});
formData.append('pinataMetadata', pinataMetadata);
// Add options
const pinataOptions = JSON.stringify({
cidVersion: 1,
})
formData.append('pinataOptions', pinataOptions)
});
formData.append('pinataOptions', pinataOptions);
console.log('Uploading to Pinata:', {
filename,
size: imageBuffer.length
})
});
// Add more detailed logging
console.log('About to send request to Pinata with headers:', {
'Authorization': 'Bearer [REDACTED]',
...formData.getHeaders(),
});
try {
// Use axios instead of fetch as it handles Node.js FormData better
const response = await axios.post(
'https://api.pinata.cloud/pinning/pinFileToIPFS',
formData,
{
headers: {
'Authorization': `Bearer ${jwt}`,
...formData.getHeaders(),
},
maxBodyLength: Infinity, // This is needed to prevent axios from limiting the size
}
);
const response = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`
},
body: formData,
})
console.log('Pinata response status:', response.status)
const data = await response.json()
console.log('Pinata response:', data)
if (!response.ok) {
throw new Error(`Failed to upload to IPFS: ${response.status} - ${JSON.stringify(data)}`)
console.log('Pinata response status:', response.status);
const data = response.data;
console.log('Pinata response:', data);
if (response.status !== 200) {
throw new Error(`Failed to upload to IPFS: ${response.status} - ${JSON.stringify(data)}`);
}
// Log the full URL that will be used
const ipfsUrl = `${baseUrl}/ipfs/${data.IpfsHash}`;
console.log('Complete IPFS URL for image:', ipfsUrl);
return ipfsUrl;
} catch (error) {
console.error('Axios request to Pinata failed:', {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
});
throw error;
}
const ipfsUrl = `${baseUrl}/ipfs/${data.IpfsHash}`
console.log('Uploaded to IPFS:', ipfsUrl)
return ipfsUrl
} catch (error) {
console.error('IPFS upload failed:', error)
throw error
console.error('IPFS upload failed:', error);
throw error;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
sub: string;
name?: string | null;
email?: string | null;
picture?: string | null;
}
}
+5 -1
View File
@@ -21,7 +21,11 @@
"name": "next"
}
],
"target": "ES2017"
"target": "ES2017",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",