update 2
This commit is contained in:
22
.env.example
22
.env.example
@@ -2,15 +2,25 @@
|
|||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker
|
# You can use either individual fields OR a full DATABASE_URL (DATABASE_URL takes precedence)
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=turftracker
|
||||||
|
DB_USER=turftracker
|
||||||
|
DB_PASSWORD=password123
|
||||||
|
# DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker
|
||||||
|
|
||||||
# JWT Secret - Change this to a strong random string in production
|
# JWT Secret - REQUIRED: Used for signing authentication tokens
|
||||||
|
# Generate a secure random string (see README for generation commands)
|
||||||
|
# NEVER use the default value in production!
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
|
|
||||||
# Google OAuth2 Configuration (Optional)
|
# Authentik OAuth2 Configuration (Optional)
|
||||||
# Get these from Google Cloud Console
|
# Configure these to enable SSO login through your Authentik instance
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id
|
AUTHENTIK_CLIENT_ID=your-authentik-client-id
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
||||||
|
AUTHENTIK_BASE_URL=https://your-authentik-domain.com
|
||||||
|
AUTHENTIK_CALLBACK_URL=http://localhost:5000/api/auth/authentik/callback
|
||||||
|
|
||||||
# Weather API Configuration
|
# Weather API Configuration
|
||||||
# Get a free API key from https://openweathermap.org/api
|
# Get a free API key from https://openweathermap.org/api
|
||||||
|
|||||||
138
README.md
138
README.md
@@ -56,17 +56,19 @@ TurfTracker is a comprehensive web application designed for homeowners to track
|
|||||||
|
|
||||||
### 🚧 Planned Features
|
### 🚧 Planned Features
|
||||||
|
|
||||||
- **Google Maps Integration** - Enhanced satellite view and area calculation
|
|
||||||
- **GPS Speed Monitoring** - Real-time speed feedback during applications
|
- **GPS Speed Monitoring** - Real-time speed feedback during applications
|
||||||
- **Mobile App** - Native iOS/Android applications
|
- **Mobile App** - Native iOS/Android applications
|
||||||
|
- **Advanced Reporting** - PDF reports and data export
|
||||||
|
- **IoT Integration** - Sensor data integration
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Frontend**: React 18, Tailwind CSS, React Router, React Query
|
- **Frontend**: React 18, Tailwind CSS, React Router, React Query, Leaflet Maps
|
||||||
- **Backend**: Node.js, Express.js, PostgreSQL
|
- **Backend**: Node.js, Express.js, PostgreSQL
|
||||||
- **Authentication**: JWT, OAuth2 (Google)
|
- **Authentication**: JWT, OAuth2 (Authentik)
|
||||||
- **Infrastructure**: Docker, Nginx
|
- **Infrastructure**: Docker, Nginx
|
||||||
- **APIs**: OpenWeatherMap, Google Maps (planned)
|
- **Maps**: OpenStreetMap via Leaflet, Esri Satellite Imagery
|
||||||
|
- **APIs**: OpenWeatherMap
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -85,23 +87,32 @@ TurfTracker is a comprehensive web application designed for homeowners to track
|
|||||||
|
|
||||||
2. **Environment Configuration**
|
2. **Environment Configuration**
|
||||||
|
|
||||||
Create environment files with your API keys:
|
Copy the example environment file and configure it:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
**Backend Environment** (create `.env` in root):
|
**Required Configuration** - Edit the `.env` file:
|
||||||
```env
|
```env
|
||||||
# Database
|
# Database - Use separate fields OR full DATABASE_URL
|
||||||
DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker
|
DB_HOST=db
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=turftracker
|
||||||
|
DB_USER=turftracker
|
||||||
|
DB_PASSWORD=password123
|
||||||
|
|
||||||
# Authentication
|
# Authentication - GENERATE A SECURE JWT SECRET
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
JWT_SECRET=your-generated-jwt-secret-here
|
||||||
|
|
||||||
# Google OAuth2 (optional)
|
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id
|
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
||||||
|
|
||||||
# Weather API (get free key from OpenWeatherMap)
|
# Weather API (get free key from OpenWeatherMap)
|
||||||
WEATHER_API_KEY=your-openweathermap-api-key
|
WEATHER_API_KEY=your-openweathermap-api-key
|
||||||
|
|
||||||
|
# Authentik OAuth2 (optional - for SSO)
|
||||||
|
AUTHENTIK_CLIENT_ID=your-authentik-client-id
|
||||||
|
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
||||||
|
AUTHENTIK_BASE_URL=https://your-authentik-domain.com
|
||||||
|
AUTHENTIK_CALLBACK_URL=http://localhost:5000/api/auth/authentik/callback
|
||||||
|
|
||||||
# App URLs
|
# App URLs
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
```
|
```
|
||||||
@@ -138,30 +149,87 @@ TurfTracker is a comprehensive web application designed for homeowners to track
|
|||||||
- Add custom products or use the pre-loaded database
|
- Add custom products or use the pre-loaded database
|
||||||
- Configure application rates
|
- Configure application rates
|
||||||
|
|
||||||
## API Keys Setup
|
## Environment Configuration Guide
|
||||||
|
|
||||||
### OpenWeatherMap API Key
|
### JWT Secret Generation
|
||||||
|
|
||||||
|
The JWT secret is used to sign authentication tokens and **MUST** be secure in production.
|
||||||
|
|
||||||
|
**Generate a secure JWT secret:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Using OpenSSL (recommended)
|
||||||
|
openssl rand -base64 64
|
||||||
|
|
||||||
|
# Option 2: Using Node.js
|
||||||
|
node -e "console.log(require('crypto').randomBytes(64).toString('base64'))"
|
||||||
|
|
||||||
|
# Option 3: Using Python
|
||||||
|
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
|
|
||||||
|
# Option 4: Online generator
|
||||||
|
# Visit: https://generate-secret.vercel.app/64
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Security Warning:** Never use the default JWT secret in production! Always generate a unique, random secret for each environment.
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
|
||||||
|
You can configure the database connection using either:
|
||||||
|
|
||||||
|
**Option 1: Separate Fields (Recommended)**
|
||||||
|
```env
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=turftracker
|
||||||
|
DB_USER=turftracker
|
||||||
|
DB_PASSWORD=password123
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Connection URL**
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://username:password@host:port/database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If both are provided, `DATABASE_URL` takes precedence.
|
||||||
|
|
||||||
|
### API Keys Setup
|
||||||
|
|
||||||
|
#### OpenWeatherMap API Key
|
||||||
|
|
||||||
1. Go to [OpenWeatherMap](https://openweathermap.org/api)
|
1. Go to [OpenWeatherMap](https://openweathermap.org/api)
|
||||||
2. Sign up for a free account
|
2. Sign up for a free account (allows 1,000 calls/day)
|
||||||
3. Get your API key from the dashboard
|
3. Navigate to "API keys" in your dashboard
|
||||||
4. Add it to your `.env` file as `WEATHER_API_KEY`
|
4. Copy your API key
|
||||||
|
5. Add it to your `.env` file as `WEATHER_API_KEY`
|
||||||
|
|
||||||
### Google OAuth2 (Optional)
|
#### Authentik OAuth2 Setup (Optional)
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
If you have an Authentik instance for SSO:
|
||||||
2. Create a new project or select existing one
|
|
||||||
3. Enable Google+ API
|
|
||||||
4. Create OAuth2 credentials
|
|
||||||
5. Add `http://localhost:5000/api/auth/google/callback` as redirect URI
|
|
||||||
6. Add client ID and secret to your `.env` file
|
|
||||||
|
|
||||||
### Google Maps API (Future Enhancement)
|
1. **In Authentik Admin Panel:**
|
||||||
|
- Go to Applications → Providers
|
||||||
|
- Create new "OAuth2/OpenID Provider"
|
||||||
|
- Set Authorization grant type: `authorization-code`
|
||||||
|
- Set Client type: `confidential`
|
||||||
|
- Set Redirect URIs: `http://localhost:5000/api/auth/authentik/callback`
|
||||||
|
- Note the Client ID and Client Secret
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
2. **In your `.env` file:**
|
||||||
2. Enable Maps JavaScript API and Geocoding API
|
```env
|
||||||
3. Create an API key
|
AUTHENTIK_CLIENT_ID=your-client-id-from-authentik
|
||||||
4. Will be integrated in future updates
|
AUTHENTIK_CLIENT_SECRET=your-client-secret-from-authentik
|
||||||
|
AUTHENTIK_BASE_URL=https://your-authentik-domain.com
|
||||||
|
AUTHENTIK_CALLBACK_URL=http://localhost:5000/api/auth/authentik/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **In Authentik Applications:**
|
||||||
|
- Create new Application
|
||||||
|
- Set Name: `TurfTracker`
|
||||||
|
- Set Slug: `turftracker`
|
||||||
|
- Set Provider: (select the provider created above)
|
||||||
|
|
||||||
|
**Scopes Required:** `openid profile email`
|
||||||
|
|
||||||
## Application Structure
|
## Application Structure
|
||||||
|
|
||||||
@@ -260,10 +328,12 @@ The backend provides a comprehensive REST API. Key endpoints include:
|
|||||||
|
|
||||||
### Project Roadmap
|
### Project Roadmap
|
||||||
|
|
||||||
- [ ] Google Maps integration for enhanced property mapping
|
- [x] OpenStreetMap integration for property mapping and satellite imagery
|
||||||
- [ ] Mobile application development
|
- [x] Authentik OAuth2 integration for SSO
|
||||||
|
- [x] Interactive lawn section drawing and area calculation
|
||||||
- [ ] GPS speed monitoring with audio feedback
|
- [ ] GPS speed monitoring with audio feedback
|
||||||
- [ ] Advanced reporting and analytics
|
- [ ] Mobile application development
|
||||||
|
- [ ] Advanced reporting and analytics with PDF export
|
||||||
- [ ] Weather-based application recommendations
|
- [ ] Weather-based application recommendations
|
||||||
- [ ] Integration with IoT sensors
|
- [ ] Integration with IoT sensors
|
||||||
- [ ] Multi-language support
|
- [ ] Multi-language support
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-oauth2": "^1.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
const pool = new Pool({
|
// Build connection configuration from environment variables
|
||||||
connectionString: process.env.DATABASE_URL,
|
const dbConfig = {
|
||||||
|
host: process.env.DB_HOST || 'db',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
database: process.env.DB_NAME || 'turftracker',
|
||||||
|
user: process.env.DB_USER || 'turftracker',
|
||||||
|
password: process.env.DB_PASSWORD || 'password123',
|
||||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||||
max: 20,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 2000,
|
connectionTimeoutMillis: 2000,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Fallback to DATABASE_URL if provided (for backwards compatibility)
|
||||||
|
const pool = process.env.DATABASE_URL
|
||||||
|
? new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
})
|
||||||
|
: new Pool(dbConfig);
|
||||||
|
|
||||||
// Test the connection
|
// Test the connection
|
||||||
pool.on('connect', () => {
|
pool.on('connect', () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
const OAuth2Strategy = require('passport-oauth2').Strategy;
|
||||||
const pool = require('../config/database');
|
const pool = require('../config/database');
|
||||||
const { validateRequest } = require('../utils/validation');
|
const { validateRequest } = require('../utils/validation');
|
||||||
const { registerSchema, loginSchema, changePasswordSchema } = require('../utils/validation');
|
const { registerSchema, loginSchema, changePasswordSchema } = require('../utils/validation');
|
||||||
@@ -10,18 +10,30 @@ const { AppError } = require('../middleware/errorHandler');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Configure Google OAuth2 strategy
|
// Configure Authentik OAuth2 strategy
|
||||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
if (process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_SECRET && process.env.AUTHENTIK_BASE_URL) {
|
||||||
passport.use(new GoogleStrategy({
|
passport.use('authentik', new OAuth2Strategy({
|
||||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
authorizationURL: `${process.env.AUTHENTIK_BASE_URL}/application/o/authorize/`,
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
tokenURL: `${process.env.AUTHENTIK_BASE_URL}/application/o/token/`,
|
||||||
callbackURL: '/api/auth/google/callback'
|
clientID: process.env.AUTHENTIK_CLIENT_ID,
|
||||||
|
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.AUTHENTIK_CALLBACK_URL || '/api/auth/authentik/callback'
|
||||||
}, async (accessToken, refreshToken, profile, done) => {
|
}, async (accessToken, refreshToken, profile, done) => {
|
||||||
try {
|
try {
|
||||||
|
// Get user info from Authentik
|
||||||
|
const axios = require('axios');
|
||||||
|
const userInfoResponse = await axios.get(`${process.env.AUTHENTIK_BASE_URL}/application/o/userinfo/`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInfo = userInfoResponse.data;
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await pool.query(
|
const existingUser = await pool.query(
|
||||||
'SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2',
|
'SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2',
|
||||||
['google', profile.id]
|
['authentik', userInfo.sub]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingUser.rows.length > 0) {
|
if (existingUser.rows.length > 0) {
|
||||||
@@ -31,14 +43,14 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|||||||
// Check if user exists with same email
|
// Check if user exists with same email
|
||||||
const emailUser = await pool.query(
|
const emailUser = await pool.query(
|
||||||
'SELECT * FROM users WHERE email = $1',
|
'SELECT * FROM users WHERE email = $1',
|
||||||
[profile.emails[0].value]
|
[userInfo.email]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (emailUser.rows.length > 0) {
|
if (emailUser.rows.length > 0) {
|
||||||
// Link Google account to existing user
|
// Link Authentik account to existing user
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE users SET oauth_provider = $1, oauth_id = $2 WHERE id = $3',
|
'UPDATE users SET oauth_provider = $1, oauth_id = $2 WHERE id = $3',
|
||||||
['google', profile.id, emailUser.rows[0].id]
|
['authentik', userInfo.sub, emailUser.rows[0].id]
|
||||||
);
|
);
|
||||||
return done(null, emailUser.rows[0]);
|
return done(null, emailUser.rows[0]);
|
||||||
}
|
}
|
||||||
@@ -48,16 +60,17 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|||||||
`INSERT INTO users (email, first_name, last_name, oauth_provider, oauth_id)
|
`INSERT INTO users (email, first_name, last_name, oauth_provider, oauth_id)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
[
|
[
|
||||||
profile.emails[0].value,
|
userInfo.email,
|
||||||
profile.name.givenName,
|
userInfo.given_name || userInfo.name?.split(' ')[0] || 'User',
|
||||||
profile.name.familyName,
|
userInfo.family_name || userInfo.name?.split(' ')[1] || '',
|
||||||
'google',
|
'authentik',
|
||||||
profile.id
|
userInfo.sub
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return done(null, newUser.rows[0]);
|
return done(null, newUser.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Authentik OAuth error:', error);
|
||||||
return done(error, null);
|
return done(error, null);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -185,18 +198,27 @@ router.post('/login', validateRequest(loginSchema), async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @route GET /api/auth/google
|
// @route GET /api/auth/authentik
|
||||||
// @desc Start Google OAuth2 flow
|
// @desc Start Authentik OAuth2 flow
|
||||||
// @access Public
|
// @access Public
|
||||||
router.get('/google', passport.authenticate('google', {
|
router.get('/authentik', (req, res, next) => {
|
||||||
scope: ['profile', 'email']
|
if (!process.env.AUTHENTIK_CLIENT_ID) {
|
||||||
}));
|
return res.status(503).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Authentik OAuth not configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
passport.authenticate('authentik', {
|
||||||
|
scope: 'openid profile email'
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// @route GET /api/auth/google/callback
|
// @route GET /api/auth/authentik/callback
|
||||||
// @desc Google OAuth2 callback
|
// @desc Authentik OAuth2 callback
|
||||||
// @access Public
|
// @access Public
|
||||||
router.get('/google/callback',
|
router.get('/authentik/callback',
|
||||||
passport.authenticate('google', { session: false }),
|
passport.authenticate('authentik', { session: false }),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
const token = generateToken(req.user.id);
|
const token = generateToken(req.user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,17 @@ services:
|
|||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=turftracker
|
||||||
|
- DB_USER=turftracker
|
||||||
|
- DB_PASSWORD=password123
|
||||||
- JWT_SECRET=your-super-secret-jwt-key
|
- JWT_SECRET=your-super-secret-jwt-key
|
||||||
- GOOGLE_CLIENT_ID=your-google-client-id
|
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-}
|
||||||
- GOOGLE_CLIENT_SECRET=your-google-client-secret
|
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
- WEATHER_API_KEY=your-weather-api-key
|
- AUTHENTIK_BASE_URL=${AUTHENTIK_BASE_URL:-}
|
||||||
|
- AUTHENTIK_CALLBACK_URL=${AUTHENTIK_CALLBACK_URL:-}
|
||||||
|
- WEATHER_API_KEY=${WEATHER_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"@google/maps": "^1.1.3",
|
"leaflet": "^1.9.4",
|
||||||
"@googlemaps/js-api-loader": "^1.16.2",
|
"react-leaflet": "^4.2.1",
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.0.18",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
@@ -24,8 +24,6 @@
|
|||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
"react-map-gl": "^7.1.7",
|
|
||||||
"mapbox-gl": "^2.15.0",
|
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
|||||||
437
frontend/src/components/Maps/PropertyMap.js
Normal file
437
frontend/src/components/Maps/PropertyMap.js
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Polygon, Marker, useMapEvents } from 'react-leaflet';
|
||||||
|
import { Icon } from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Fix for default markers in react-leaflet
|
||||||
|
delete Icon.Default.prototype._getIconUrl;
|
||||||
|
Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
|
||||||
|
iconUrl: require('leaflet/dist/images/marker-icon.png'),
|
||||||
|
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom component to handle map clicks for drawing polygons
|
||||||
|
const DrawingHandler = ({ isDrawing, onPointAdd, onDrawingComplete }) => {
|
||||||
|
useMapEvents({
|
||||||
|
click: (e) => {
|
||||||
|
if (isDrawing) {
|
||||||
|
onPointAdd([e.latlng.lat, e.latlng.lng]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keydown: (e) => {
|
||||||
|
if (e.originalEvent.key === 'Escape' && isDrawing) {
|
||||||
|
onDrawingComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate polygon area in square feet
|
||||||
|
const calculatePolygonArea = (coordinates) => {
|
||||||
|
if (!coordinates || coordinates.length < 3) return 0;
|
||||||
|
|
||||||
|
// Shoelace formula for polygon area
|
||||||
|
let area = 0;
|
||||||
|
const n = coordinates.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n;
|
||||||
|
area += coordinates[i][0] * coordinates[j][1];
|
||||||
|
area -= coordinates[j][0] * coordinates[i][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
area = Math.abs(area) / 2;
|
||||||
|
|
||||||
|
// Convert from decimal degrees to square feet (approximate)
|
||||||
|
const avgLat = coordinates.reduce((sum, coord) => sum + coord[0], 0) / n;
|
||||||
|
const meterToFeet = 3.28084;
|
||||||
|
const degToMeter = 111320 * Math.cos(avgLat * Math.PI / 180);
|
||||||
|
|
||||||
|
return area * Math.pow(degToMeter * meterToFeet, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PropertyMap = ({
|
||||||
|
center = [39.8283, -98.5795], // Center of USA as default
|
||||||
|
zoom = 15,
|
||||||
|
property,
|
||||||
|
sections = [],
|
||||||
|
onSectionCreate,
|
||||||
|
onSectionUpdate,
|
||||||
|
onSectionDelete,
|
||||||
|
onPropertyUpdate,
|
||||||
|
editable = false,
|
||||||
|
className = "h-96 w-full"
|
||||||
|
}) => {
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [currentPolygon, setCurrentPolygon] = useState([]);
|
||||||
|
const [selectedSection, setSelectedSection] = useState(null);
|
||||||
|
const [showSectionForm, setShowSectionForm] = useState(false);
|
||||||
|
const [sectionName, setSectionName] = useState('');
|
||||||
|
const [grassType, setGrassType] = useState('');
|
||||||
|
const [soilType, setSoilType] = useState('');
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
|
||||||
|
// Handle adding points to current polygon
|
||||||
|
const handlePointAdd = useCallback((point) => {
|
||||||
|
setCurrentPolygon(prev => [...prev, point]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle completing polygon drawing
|
||||||
|
const handleDrawingComplete = useCallback(() => {
|
||||||
|
if (currentPolygon.length >= 3) {
|
||||||
|
const area = calculatePolygonArea(currentPolygon);
|
||||||
|
setShowSectionForm(true);
|
||||||
|
} else {
|
||||||
|
setCurrentPolygon([]);
|
||||||
|
}
|
||||||
|
setIsDrawing(false);
|
||||||
|
}, [currentPolygon]);
|
||||||
|
|
||||||
|
// Start drawing mode
|
||||||
|
const startDrawing = () => {
|
||||||
|
setIsDrawing(true);
|
||||||
|
setCurrentPolygon([]);
|
||||||
|
setSelectedSection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel drawing
|
||||||
|
const cancelDrawing = () => {
|
||||||
|
setIsDrawing(false);
|
||||||
|
setCurrentPolygon([]);
|
||||||
|
setShowSectionForm(false);
|
||||||
|
setSectionName('');
|
||||||
|
setGrassType('');
|
||||||
|
setSoilType('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save new section
|
||||||
|
const saveSection = async () => {
|
||||||
|
if (!sectionName.trim() || currentPolygon.length < 3) return;
|
||||||
|
|
||||||
|
const area = calculatePolygonArea(currentPolygon);
|
||||||
|
const sectionData = {
|
||||||
|
name: sectionName,
|
||||||
|
area,
|
||||||
|
polygonData: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [currentPolygon.map(([lat, lng]) => [lng, lat])] // GeoJSON format
|
||||||
|
},
|
||||||
|
grassType: grassType || null,
|
||||||
|
soilType: soilType || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (onSectionCreate) {
|
||||||
|
await onSectionCreate(sectionData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
cancelDrawing();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating section:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle section click
|
||||||
|
const handleSectionClick = (section) => {
|
||||||
|
setSelectedSection(selectedSection?.id === section.id ? null : section);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete selected section
|
||||||
|
const deleteSelectedSection = async () => {
|
||||||
|
if (!selectedSection || !onSectionDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSectionDelete(selectedSection.id);
|
||||||
|
setSelectedSection(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting section:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get section color based on grass type
|
||||||
|
const getSectionColor = (section) => {
|
||||||
|
const colors = {
|
||||||
|
'bermuda': '#10b981',
|
||||||
|
'fescue': '#059669',
|
||||||
|
'kentucky bluegrass': '#047857',
|
||||||
|
'zoysia': '#065f46',
|
||||||
|
'st augustine': '#064e3b',
|
||||||
|
'centipede': '#6ee7b7',
|
||||||
|
'default': '#3b82f6'
|
||||||
|
};
|
||||||
|
|
||||||
|
return colors[section.grassType?.toLowerCase()] || colors.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{/* Map Controls */}
|
||||||
|
{editable && (
|
||||||
|
<div className="absolute top-4 right-4 z-10 flex flex-col space-y-2">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-2">
|
||||||
|
{!isDrawing ? (
|
||||||
|
<button
|
||||||
|
onClick={startDrawing}
|
||||||
|
className="btn-primary text-sm px-3 py-2"
|
||||||
|
title="Draw new lawn section"
|
||||||
|
>
|
||||||
|
Draw Section
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDrawingComplete}
|
||||||
|
className="btn-success text-sm px-3 py-2"
|
||||||
|
disabled={currentPolygon.length < 3}
|
||||||
|
title="Finish drawing (or press Escape)"
|
||||||
|
>
|
||||||
|
Finish ({currentPolygon.length} points)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelDrawing}
|
||||||
|
className="btn-secondary text-sm px-3 py-2"
|
||||||
|
title="Cancel drawing"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSection && (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-3">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-2">{selectedSection.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
|
{Math.round(selectedSection.area).toLocaleString()} sq ft
|
||||||
|
</p>
|
||||||
|
{selectedSection.grassType && (
|
||||||
|
<p className="text-xs text-gray-500 mb-1">
|
||||||
|
Grass: {selectedSection.grassType}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedSection.soilType && (
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Soil: {selectedSection.soilType}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={deleteSelectedSection}
|
||||||
|
className="btn-danger text-xs px-2 py-1 w-full"
|
||||||
|
>
|
||||||
|
Delete Section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Creation Form Modal */}
|
||||||
|
{showSectionForm && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
New Lawn Section
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Section Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sectionName}
|
||||||
|
onChange={(e) => setSectionName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="e.g., Front Yard, Back Lawn"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Grass Type</label>
|
||||||
|
<select
|
||||||
|
value={grassType}
|
||||||
|
onChange={(e) => setGrassType(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Select grass type</option>
|
||||||
|
<option value="bermuda">Bermuda</option>
|
||||||
|
<option value="fescue">Fescue</option>
|
||||||
|
<option value="kentucky bluegrass">Kentucky Bluegrass</option>
|
||||||
|
<option value="zoysia">Zoysia</option>
|
||||||
|
<option value="st augustine">St. Augustine</option>
|
||||||
|
<option value="centipede">Centipede</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Soil Type</label>
|
||||||
|
<select
|
||||||
|
value={soilType}
|
||||||
|
onChange={(e) => setSoilType(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Select soil type</option>
|
||||||
|
<option value="clay">Clay</option>
|
||||||
|
<option value="sand">Sand</option>
|
||||||
|
<option value="loam">Loam</option>
|
||||||
|
<option value="silt">Silt</option>
|
||||||
|
<option value="rocky">Rocky</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Area: {Math.round(calculatePolygonArea(currentPolygon)).toLocaleString()} sq ft
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{currentPolygon.length} points drawn
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={cancelDrawing}
|
||||||
|
className="btn-outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveSection}
|
||||||
|
disabled={!sectionName.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Save Section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={zoom}
|
||||||
|
className="h-full w-full rounded-lg"
|
||||||
|
ref={mapRef}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Satellite imagery option */}
|
||||||
|
<TileLayer
|
||||||
|
attribution='Tiles © Esri — Source: Esri, Maxar, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community'
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawing handler */}
|
||||||
|
<DrawingHandler
|
||||||
|
isDrawing={isDrawing}
|
||||||
|
onPointAdd={handlePointAdd}
|
||||||
|
onDrawingComplete={handleDrawingComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Property marker */}
|
||||||
|
{property && property.latitude && property.longitude && (
|
||||||
|
<Marker
|
||||||
|
position={[property.latitude, property.longitude]}
|
||||||
|
title={property.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing sections */}
|
||||||
|
{sections.map((section) => {
|
||||||
|
if (!section.polygonData?.coordinates?.[0]) return null;
|
||||||
|
|
||||||
|
const coordinates = section.polygonData.coordinates[0].map(([lng, lat]) => [lat, lng]);
|
||||||
|
const isSelected = selectedSection?.id === section.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Polygon
|
||||||
|
key={section.id}
|
||||||
|
positions={coordinates}
|
||||||
|
pathOptions={{
|
||||||
|
fillColor: getSectionColor(section),
|
||||||
|
fillOpacity: isSelected ? 0.6 : 0.4,
|
||||||
|
color: getSectionColor(section),
|
||||||
|
weight: isSelected ? 3 : 2,
|
||||||
|
opacity: isSelected ? 1 : 0.8,
|
||||||
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => handleSectionClick(section)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Current polygon being drawn */}
|
||||||
|
{currentPolygon.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Show markers for each point */}
|
||||||
|
{currentPolygon.map((point, index) => (
|
||||||
|
<Marker
|
||||||
|
key={index}
|
||||||
|
position={point}
|
||||||
|
icon={new Icon({
|
||||||
|
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iNCIgZmlsbD0iIzNiODJmNiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=',
|
||||||
|
iconSize: [12, 12],
|
||||||
|
iconAnchor: [6, 6]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Show polygon if we have enough points */}
|
||||||
|
{currentPolygon.length >= 3 && (
|
||||||
|
<Polygon
|
||||||
|
positions={currentPolygon}
|
||||||
|
pathOptions={{
|
||||||
|
fillColor: '#3b82f6',
|
||||||
|
fillOpacity: 0.3,
|
||||||
|
color: '#3b82f6',
|
||||||
|
weight: 2,
|
||||||
|
dashArray: '5, 5'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Drawing instructions */}
|
||||||
|
{isDrawing && (
|
||||||
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3 max-w-xs">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<strong>Drawing Mode:</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Click to add points. Press <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Escape</kbd> or
|
||||||
|
click "Finish" when done.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Need at least 3 points to create a section.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section stats */}
|
||||||
|
{sections.length > 0 && !isDrawing && (
|
||||||
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
{sections.length} Section{sections.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Total: {sections.reduce((sum, section) => sum + (section.area || 0), 0).toLocaleString()} sq ft
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyMap;
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Import Leaflet CSS for maps */
|
||||||
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ const Login = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleAuthentikLogin = () => {
|
||||||
// Redirect to Google OAuth endpoint
|
// Redirect to Authentik OAuth endpoint
|
||||||
window.location.href = `${process.env.REACT_APP_API_URL || 'http://localhost:5000'}/api/auth/google`;
|
window.location.href = `${process.env.REACT_APP_API_URL || 'http://localhost:5000'}/api/auth/authentik`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,28 +170,14 @@ const Login = () => {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGoogleLogin}
|
onClick={handleAuthentikLogin}
|
||||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path
|
<path d="M12 2L2 7v10c0 5.55 3.84 9.74 9 11 5.16-1.26 9-5.45 9-11V7l-10-5z"/>
|
||||||
fill="currentColor"
|
<path d="M10 17l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" fill="white"/>
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="ml-2">Sign in with Google</span>
|
<span className="ml-2">Sign in with Authentik</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ http {
|
|||||||
add_header X-Frame-Options DENY;
|
add_header X-Frame-Options DENY;
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://maps.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://maps.googleapis.com https://maps.gstatic.com; connect-src 'self' https://api.openweathermap.org;";
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://*.openstreetmap.org https://server.arcgisonline.com; connect-src 'self' https://api.openweathermap.org https://*.openstreetmap.org https://server.arcgisonline.com;";
|
||||||
|
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server backend:5000;
|
server backend:5000;
|
||||||
|
|||||||
Reference in New Issue
Block a user