genesis
This commit is contained in:
4
.cursor/rules/devdatabase.mdc
Normal file
4
.cursor/rules/devdatabase.mdc
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
sqlcmd -C -S tcp:192.168.56.1,1497 -U app -P 'readonly' -d eazybusiness -W
|
||||
10
.env
Normal file
10
.env
Normal file
@@ -0,0 +1,10 @@
|
||||
GOOGLE_CLIENT_ID=928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
REACT_APP_GOOGLE_CLIENT_ID=928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com
|
||||
AUTHORIZED_EMAILS=sebgreenbus@gmail.com,growsdd@gmail.com
|
||||
JWT_SECRET=7vK2gQp9zX1wR4eT6sB8uN0cLmY5aV3j
|
||||
DB_SERVER=192.168.56.1
|
||||
DB_DATABASE=eazybusiness
|
||||
DB_USERNAME=app
|
||||
DB_PASSWORD=readonly
|
||||
DB_PORT=1497
|
||||
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# Google OAuth Configuration
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
|
||||
# Frontend Environment Variables (REACT_APP_ prefix required)
|
||||
REACT_APP_GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# Authorized Email Addresses (comma-separated)
|
||||
AUTHORIZED_EMAILS=admin@example.com,user1@example.com,user2@example.com
|
||||
|
||||
# MSSQL Database Configuration
|
||||
DB_SERVER=your_mssql_server_here
|
||||
DB_DATABASE=your_database_name_here
|
||||
DB_USERNAME=your_db_username_here
|
||||
DB_PASSWORD=your_db_password_here
|
||||
DB_PORT=1433
|
||||
|
||||
# Server Configuration
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
363
README.md
Normal file
363
README.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# FibDash
|
||||
|
||||
A modern React Material-UI dashboard application with Google SSO authentication and MSSQL database integration.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **React 18** with class components
|
||||
- 🎨 **Material-UI (MUI)** for beautiful, modern UI
|
||||
- 🔐 **Google SSO Authentication**
|
||||
- 🗄️ **MSSQL Database** integration
|
||||
- ⚡ **Webpack Dev Server** with Hot Module Reload
|
||||
- 🔄 **Express API** with hot reload via nodemon
|
||||
- 🛡️ **JWT Authentication** for API security
|
||||
- 📱 **Responsive Design**
|
||||
- 🎯 **Production Ready** build configuration
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
fibdash/
|
||||
├── client/ # Frontend React application
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React class components
|
||||
│ │ ├── services/ # API service classes
|
||||
│ │ ├── App.js # Main application component
|
||||
│ │ └── index.js # React entry point
|
||||
│ └── public/
|
||||
│ └── index.html # HTML template
|
||||
├── src/ # Backend Express API
|
||||
│ ├── config/ # Database configuration
|
||||
│ ├── middleware/ # Authentication middleware
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── database/ # Database schema
|
||||
│ └── index.js # Express server entry point
|
||||
├── webpack.config.js # Webpack configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v16 or higher)
|
||||
- MSSQL Server instance
|
||||
- Google Cloud Platform account for OAuth setup
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Clone and Install Dependencies
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd fibdash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
Copy the example environment file and configure your settings:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your actual configuration:
|
||||
|
||||
```env
|
||||
# Google OAuth Configuration
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
|
||||
# Frontend Environment Variables (REACT_APP_ prefix required)
|
||||
REACT_APP_GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
|
||||
# JWT Secret (generate a secure random string)
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# MSSQL Database Configuration
|
||||
DB_SERVER=your_mssql_server_here
|
||||
DB_DATABASE=your_database_name_here
|
||||
DB_USERNAME=your_db_username_here
|
||||
DB_PASSWORD=your_db_password_here
|
||||
DB_PORT=1433
|
||||
|
||||
# Server Configuration
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### 3. Google OAuth Setup
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the Google+ API
|
||||
4. Create OAuth 2.0 credentials:
|
||||
- Application type: Web application
|
||||
- Authorized JavaScript origins: `http://localhost:3000`
|
||||
- Authorized redirect URIs: `http://localhost:3000`
|
||||
5. Copy the Client ID and Client Secret to your `.env` file
|
||||
|
||||
### 4. Database Setup
|
||||
|
||||
1. Create a new MSSQL database
|
||||
2. Run the schema creation script:
|
||||
|
||||
```bash
|
||||
# Connect to your MSSQL server and run:
|
||||
sqlcmd -S your_server -d your_database -i src/database/schema.sql
|
||||
```
|
||||
|
||||
Or manually execute the SQL commands in `src/database/schema.sql`
|
||||
|
||||
### 5. Start Development Servers
|
||||
|
||||
Run both frontend and backend development servers:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start:
|
||||
- Frontend dev server on `http://localhost:5001` (with hot reload)
|
||||
- Backend API server on `http://localhost:5000` (with nodemon auto-restart)
|
||||
|
||||
Or run them separately:
|
||||
|
||||
```bash
|
||||
# Frontend only
|
||||
npm run dev:frontend
|
||||
|
||||
# Backend only
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
### 6. Optional: Nginx Setup for Development
|
||||
|
||||
For a more production-like development environment, you can set up nginx as a reverse proxy:
|
||||
|
||||
#### Automatic Setup (Linux/macOS)
|
||||
```bash
|
||||
npm run setup:nginx
|
||||
```
|
||||
|
||||
#### Manual Setup
|
||||
1. Install nginx on your system
|
||||
2. Copy the nginx configuration:
|
||||
```bash
|
||||
sudo cp nginx.simple.conf /etc/nginx/sites-available/fibdash-dev
|
||||
sudo ln -s /etc/nginx/sites-available/fibdash-dev /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
3. Add to your hosts file (optional):
|
||||
```bash
|
||||
echo "127.0.0.1 fibdash.local" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
With nginx setup, you can access:
|
||||
- **Main app**: `http://localhost/` or `http://fibdash.local/`
|
||||
- **API**: `http://localhost/api/` or `http://fibdash.local/api/`
|
||||
- **Direct Frontend**: `http://localhost:5001/`
|
||||
- **Direct Backend**: `http://localhost:5000/`
|
||||
|
||||
### Production Nginx Setup
|
||||
|
||||
For production deployment, use the production nginx configuration:
|
||||
|
||||
```bash
|
||||
# Copy production nginx config
|
||||
sudo cp nginx.prod.conf /etc/nginx/sites-available/fibdash-prod
|
||||
sudo ln -s /etc/nginx/sites-available/fibdash-prod /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
The production config includes:
|
||||
- **Security headers** (CSP, XSS protection, etc.)
|
||||
- **Static asset caching** (1 year for JS/CSS/images)
|
||||
- **Gzip compression** for better performance
|
||||
- **SSL/HTTPS support** (commented out, ready to enable)
|
||||
|
||||
## Email Authorization
|
||||
|
||||
FibDash includes built-in email authorization to restrict access to specific users.
|
||||
|
||||
### Setup Authorization
|
||||
|
||||
1. **Add authorized emails to your `.env` file:**
|
||||
```env
|
||||
AUTHORIZED_EMAILS=admin@yourcompany.com,user1@yourcompany.com,user2@yourcompany.com
|
||||
```
|
||||
|
||||
2. **First email is admin**: The first email in the list automatically gets admin privileges
|
||||
|
||||
3. **No authorization**: If `AUTHORIZED_EMAILS` is not set or empty, **NO USERS** can access the app
|
||||
|
||||
### Admin Features
|
||||
|
||||
Admins (first email in the authorized list) can:
|
||||
- View all authorized emails: `GET /api/admin/authorized-emails`
|
||||
- Add new authorized email: `POST /api/admin/authorized-emails`
|
||||
- Remove authorized email: `DELETE /api/admin/authorized-emails/:email`
|
||||
- View system info: `GET /api/admin/system-info`
|
||||
|
||||
**Note**: Admin changes via API are temporary. For permanent changes, update the `.env` file.
|
||||
|
||||
### Authorization Flow
|
||||
|
||||
1. User signs in with Google
|
||||
2. Backend checks if email is in `AUTHORIZED_EMAILS` list
|
||||
3. If authorized → login succeeds
|
||||
4. If not authorized → "Access denied" error
|
||||
5. All API endpoints check authorization via middleware
|
||||
|
||||
## Development
|
||||
|
||||
### Frontend Development
|
||||
|
||||
- Frontend code is in the `client/` directory
|
||||
- Uses Webpack dev server with hot module reload
|
||||
- All components are class components (no function components)
|
||||
- Material-UI for styling and components
|
||||
- Authentication handled via Google SSO
|
||||
|
||||
### Backend Development
|
||||
|
||||
- Backend code is in the `src/` directory
|
||||
- Express.js API server with CORS enabled
|
||||
- JWT authentication middleware
|
||||
- MSSQL database integration
|
||||
- Auto-restart on file changes via nodemon
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- `POST /api/auth/google` - Google OAuth login
|
||||
- `GET /api/auth/verify` - Verify JWT token
|
||||
- `POST /api/auth/logout` - Logout user
|
||||
- `GET /api/dashboard` - Get dashboard data
|
||||
- `GET /api/dashboard/user` - Get user-specific data
|
||||
- `GET /api/health` - Health check
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Start both frontend and backend in development mode |
|
||||
| `npm run dev:frontend` | Start only the frontend webpack dev server |
|
||||
| `npm run dev:backend` | Start only the backend API server with nodemon |
|
||||
| `npm run build` | Create production build of frontend |
|
||||
| `npm run build:prod` | Build and start production server |
|
||||
| `npm start` | Build frontend and start production server |
|
||||
| `npm run start:prod` | Start production server (assumes build exists) |
|
||||
| `npm run setup:nginx` | Automatically setup nginx for development |
|
||||
| `npm run nginx:test` | Test nginx configuration |
|
||||
| `npm run nginx:reload` | Reload nginx configuration |
|
||||
| `npm run nginx:start` | Start nginx service |
|
||||
| `npm run nginx:stop` | Stop nginx service |
|
||||
| `npm run nginx:status` | Check nginx service status |
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Single-Process Production Setup
|
||||
|
||||
In production, the Express backend builds the React frontend and serves it as static files. No separate frontend server is needed.
|
||||
|
||||
#### Build and Start Production Server
|
||||
|
||||
```bash
|
||||
# Build frontend and start server in one command
|
||||
npm start
|
||||
|
||||
# Or build and start separately
|
||||
npm run build
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
#### Production Architecture
|
||||
|
||||
```
|
||||
Production Setup:
|
||||
┌─────────────────────────────────────┐
|
||||
│ Single Express Server (Port 5000) │
|
||||
├─────────────────────────────────────┤
|
||||
│ • Serves static React files │
|
||||
│ • Handles API routes (/api/*) │
|
||||
│ • Manages authentication │
|
||||
│ • Connects to MSSQL database │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Production Features
|
||||
|
||||
- **Single Process**: One Node.js server handles everything
|
||||
- **Static File Serving**: Built React app served with caching headers
|
||||
- **Optimized Build**: Minified JS/CSS with content hashing
|
||||
- **Code Splitting**: Vendor libraries separated for better caching
|
||||
- **Production Security**: CSP headers and security optimizations
|
||||
|
||||
## Project Structure Details
|
||||
|
||||
### Frontend (`client/`)
|
||||
|
||||
- **Components**: All React components using class-based architecture
|
||||
- **Services**: API communication classes
|
||||
- **Material-UI**: Modern UI components with custom theming
|
||||
- **Hot Reload**: Webpack dev server with HMR enabled
|
||||
|
||||
### Backend (`src/`)
|
||||
|
||||
- **Express Server**: RESTful API with middleware
|
||||
- **Authentication**: Google OAuth + JWT tokens
|
||||
- **Database**: MSSQL with connection pooling
|
||||
- **Hot Reload**: Nodemon for automatic server restart
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth Client ID (backend) | Yes |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret (backend) | Yes |
|
||||
| `REACT_APP_GOOGLE_CLIENT_ID` | Google OAuth Client ID (frontend) | Yes |
|
||||
| `JWT_SECRET` | Secret for JWT token signing | Yes |
|
||||
| `AUTHORIZED_EMAILS` | Comma-separated list of authorized email addresses | No* |
|
||||
| `DB_SERVER` | MSSQL server address | Yes |
|
||||
| `DB_DATABASE` | Database name | Yes |
|
||||
| `DB_USERNAME` | Database username | Yes |
|
||||
| `DB_PASSWORD` | Database password | Yes |
|
||||
| `DB_PORT` | Database port (default: 1433) | No |
|
||||
| `PORT` | API server port (default: 5000) | No |
|
||||
| `NODE_ENV` | Environment mode | No |
|
||||
|
||||
*If `AUTHORIZED_EMAILS` is not set or empty, **NO USERS** can access the application. Only email addresses listed in `AUTHORIZED_EMAILS` can log in.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
1. Verify your MSSQL server is running and accessible
|
||||
2. Check firewall settings allow connections on port 1433
|
||||
3. Ensure your database credentials are correct
|
||||
4. Check the server logs for detailed error messages
|
||||
|
||||
### Google OAuth Issues
|
||||
|
||||
1. Verify your Google Client ID is correctly set
|
||||
2. Check that your domain is authorized in Google Cloud Console
|
||||
3. Ensure the OAuth consent screen is configured
|
||||
4. Make sure you're testing on the correct domain/port
|
||||
|
||||
**For detailed Google OAuth troubleshooting, see: [docs/GOOGLE_OAUTH_SETUP.md](docs/GOOGLE_OAUTH_SETUP.md)**
|
||||
|
||||
Common fixes for CORS/GSI errors:
|
||||
- Add your domain to Google Cloud Console authorized origins
|
||||
- Ensure both `GOOGLE_CLIENT_ID` and `REACT_APP_GOOGLE_CLIENT_ID` are set
|
||||
- Use the "Alternative Google Sign-In" button as fallback
|
||||
- Consider enabling HTTPS for better OAuth compatibility
|
||||
|
||||
### Hot Reload Not Working
|
||||
|
||||
1. Check that both dev servers are running
|
||||
2. Verify webpack proxy configuration
|
||||
3. Clear browser cache and restart dev servers
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
12
client/public/index.html
Normal file
12
client/public/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FibDash</title>
|
||||
<meta name="google-signin-client_id" content="%REACT_APP_GOOGLE_CLIENT_ID%">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
141
client/src/App.js
Normal file
141
client/src/App.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
|
||||
import LoginIcon from '@mui/icons-material/Login';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import AuthService from './services/AuthService';
|
||||
import DataViewer from './components/DataViewer';
|
||||
import Login from './components/Login';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: true,
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.checkAuthStatus();
|
||||
}
|
||||
|
||||
checkAuthStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const user = await this.authService.verifyToken(token);
|
||||
if (user) {
|
||||
this.setState({ isAuthenticated: true, user, loading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
this.setState({ loading: false });
|
||||
};
|
||||
|
||||
handleLogin = async (tokenResponse) => {
|
||||
try {
|
||||
const result = await this.authService.googleLogin(tokenResponse);
|
||||
if (result.success) {
|
||||
localStorage.setItem('token', result.token);
|
||||
this.setState({ isAuthenticated: true, user: result.user });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
// The error handling will be done in the Login component's handleGoogleResponse
|
||||
throw error; // Re-throw to let Login component handle the display
|
||||
}
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
this.setState({ isAuthenticated: false, user: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isAuthenticated, user, loading } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Container>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||
<Typography>Lädt...</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<DashboardIcon sx={{ mr: { xs: 1, sm: 2 } }} />
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
FibDash
|
||||
</Typography>
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mr: { xs: 1, sm: 2 },
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
Willkommen, {user.name}
|
||||
</Typography>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={this.handleLogout}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: { xs: 'auto', sm: 'auto' },
|
||||
px: { xs: 1, sm: 2 }
|
||||
}}
|
||||
>
|
||||
<LoginIcon sx={{ mr: { xs: 0, sm: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
||||
Abmelden
|
||||
</Box>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
{isAuthenticated ? (
|
||||
<DataViewer user={user} />
|
||||
) : (
|
||||
<Login onLogin={this.handleLogin} />
|
||||
)}
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
199
client/src/components/Dashboard.js
Normal file
199
client/src/components/Dashboard.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Paper,
|
||||
Avatar,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
People as PeopleIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Timeline as TimelineIcon,
|
||||
} from '@mui/icons-material';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
class Dashboard extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
dashboardData: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadDashboardData();
|
||||
}
|
||||
|
||||
loadDashboardData = async () => {
|
||||
try {
|
||||
const response = await this.authService.apiCall('/dashboard');
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
// Map icon names to actual components
|
||||
const statsWithIcons = data.stats.map(stat => ({
|
||||
...stat,
|
||||
icon: this.getIconComponent(stat.icon)
|
||||
}));
|
||||
this.setState({
|
||||
dashboardData: { ...data, stats: statsWithIcons },
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
// Fallback data when API is not available
|
||||
this.setState({
|
||||
dashboardData: {
|
||||
stats: [
|
||||
{ title: 'Total Users', value: 'Loading...', icon: PeopleIcon, color: '#1976d2' },
|
||||
{ title: 'Revenue', value: 'Loading...', icon: TrendingUpIcon, color: '#388e3c' },
|
||||
{ title: 'Reports', value: 'Loading...', icon: AssessmentIcon, color: '#f57c00' },
|
||||
{ title: 'Growth', value: 'Loading...', icon: TimelineIcon, color: '#7b1fa2' },
|
||||
],
|
||||
recentActivity: []
|
||||
},
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
this.setState({ error: 'Failed to load dashboard data', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
getIconComponent = (iconName) => {
|
||||
const iconMap = {
|
||||
'PeopleIcon': PeopleIcon,
|
||||
'TrendingUpIcon': TrendingUpIcon,
|
||||
'AssessmentIcon': AssessmentIcon,
|
||||
'TimelineIcon': TimelineIcon,
|
||||
};
|
||||
return iconMap[iconName] || PeopleIcon;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
const { dashboardData, loading, error } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<Typography>Dashboard lädt...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<Typography color="error">{error}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = dashboardData?.stats || [];
|
||||
const recentActivity = dashboardData?.recentActivity || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* User Welcome Section */}
|
||||
<Paper elevation={1} sx={{ p: 3, mb: 4, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Avatar
|
||||
src={user?.picture}
|
||||
alt={user?.name}
|
||||
sx={{ width: 64, height: 64, mr: 3 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ color: 'white', fontWeight: 'bold' }}>
|
||||
Willkommen zurück, {user?.name}!
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||
{user?.email}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Aktiv"
|
||||
size="small"
|
||||
sx={{ mt: 1, backgroundColor: 'rgba(255, 255, 255, 0.2)', color: 'white' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{stats.map((stat, index) => {
|
||||
const IconComponent = typeof stat.icon === 'string' ? this.getIconComponent(stat.icon) : stat.icon;
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<Card elevation={2} sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="h6" component="div" gutterBottom>
|
||||
{stat.title}
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: stat.color,
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IconComponent sx={{ color: 'white', fontSize: 32 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Paper elevation={2} sx={{ p: 3 }}>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Letzte Aktivitäten
|
||||
</Typography>
|
||||
{recentActivity.length > 0 ? (
|
||||
<Box>
|
||||
{recentActivity.map((activity, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: index < recentActivity.length - 1 ? '1px solid #e0e0e0' : 'none',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{activity.description}</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{activity.timestamp}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
Keine aktuellen Aktivitäten vorhanden. Beginnen Sie mit der Nutzung des Systems, um hier Updates zu sehen.
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
141
client/src/components/DataViewer.js
Normal file
141
client/src/components/DataViewer.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import AuthService from '../services/AuthService';
|
||||
import SummaryHeader from './SummaryHeader';
|
||||
import TransactionsTable from './TransactionsTable';
|
||||
|
||||
class DataViewer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
months: [],
|
||||
selectedMonth: '',
|
||||
transactions: [],
|
||||
summary: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadMonths();
|
||||
}
|
||||
|
||||
loadMonths = async () => {
|
||||
try {
|
||||
const response = await this.authService.apiCall('/data/months');
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
this.setState({
|
||||
months: data.months,
|
||||
selectedMonth: data.months[0] || '', // Select newest month
|
||||
loading: false
|
||||
});
|
||||
|
||||
// Load data for the newest month
|
||||
if (data.months[0]) {
|
||||
this.loadTransactions(data.months[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading months:', error);
|
||||
this.setState({ error: 'Fehler beim Laden der Monate', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
loadTransactions = async (monthYear) => {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const response = await this.authService.apiCall(`/data/transactions/${monthYear}`);
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
this.setState({
|
||||
transactions: data.transactions,
|
||||
summary: data.summary,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading transactions:', error);
|
||||
this.setState({ error: 'Fehler beim Laden der Transaktionen', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleMonthChange = (event) => {
|
||||
const monthYear = event.target.value;
|
||||
this.setState({ selectedMonth: monthYear });
|
||||
this.loadTransactions(monthYear);
|
||||
};
|
||||
|
||||
|
||||
|
||||
downloadDatev = async () => {
|
||||
const { selectedMonth } = this.state;
|
||||
if (!selectedMonth) return;
|
||||
|
||||
try {
|
||||
const response = await this.authService.apiCall(`/data/datev/${selectedMonth}`);
|
||||
if (response && response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `DATEV_${selectedMonth.replace('-', '_')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading DATEV:', error);
|
||||
this.setState({ error: 'Fehler beim Herunterladen der DATEV-Datei' });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
|
||||
|
||||
if (loading && !transactions.length) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SummaryHeader
|
||||
months={months}
|
||||
selectedMonth={selectedMonth}
|
||||
summary={summary}
|
||||
loading={loading}
|
||||
onMonthChange={this.handleMonthChange}
|
||||
onDownloadDatev={this.downloadDatev}
|
||||
/>
|
||||
|
||||
<TransactionsTable
|
||||
transactions={transactions}
|
||||
selectedMonth={selectedMonth}
|
||||
loading={loading}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataViewer;
|
||||
166
client/src/components/Login.js
Normal file
166
client/src/components/Login.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Box, Paper, Typography, Button, Alert } from '@mui/material';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
|
||||
class Login extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: null,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadGoogleScript();
|
||||
}
|
||||
|
||||
loadGoogleScript = () => {
|
||||
if (window.google && window.google.accounts) {
|
||||
this.initializeGoogleSignIn();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
this.initializeGoogleSignIn();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
initializeGoogleSignIn = () => {
|
||||
if (window.google && window.google.accounts) {
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID || 'your_google_client_id_here',
|
||||
callback: this.handleGoogleResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
console.log('✅ Google Sign-In initialized');
|
||||
} catch (error) {
|
||||
console.error('Google Sign-In initialization error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleGoogleResponse = (response) => {
|
||||
this.setState({ loading: true, error: null });
|
||||
this.props.onLogin(response)
|
||||
.catch((error) => {
|
||||
console.error('Login error details:', error);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error response:', error.response);
|
||||
|
||||
let errorMessage = 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.';
|
||||
|
||||
// Check if it's an authorization error
|
||||
if (error.message) {
|
||||
if (error.message.includes('Access denied') ||
|
||||
error.message.includes('not authorized') ||
|
||||
error.message.includes('403')) {
|
||||
errorMessage = '🚫 Zugriff verweigert: Ihre E-Mail-Adresse ist nicht autorisiert. Versuchen Sie, sich mit einem anderen Google-Konto anzumelden.';
|
||||
} else if (error.message.includes('No authorized users configured')) {
|
||||
errorMessage = '🔒 Kein Zugriff: Derzeit sind keine Benutzer autorisiert. Wenden Sie sich an den Administrator.';
|
||||
} else {
|
||||
// Show the actual error message from the server
|
||||
errorMessage = `❌ Anmeldefehler: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ error: errorMessage });
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
};
|
||||
|
||||
handleGoogleLogin = () => {
|
||||
// If there was a previous error, we need to reset completely
|
||||
if (this.state.error) {
|
||||
console.log('🔄 Previous error detected, reloading page...');
|
||||
this.setState({ loading: true });
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any previous error
|
||||
this.setState({ error: null, loading: false });
|
||||
|
||||
if (window.google && window.google.accounts && window.google.accounts.id) {
|
||||
try {
|
||||
window.google.accounts.id.prompt();
|
||||
} catch (error) {
|
||||
console.error('Google prompt error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung konnte nicht geladen werden. Die Seite wird aktualisiert, um es erneut zu versuchen.',
|
||||
loading: true
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung nicht geladen. Die Seite wird aktualisiert, um es erneut zu versuchen.',
|
||||
loading: true
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
const { error, loading } = this.state;
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="60vh"
|
||||
>
|
||||
<Paper elevation={3} sx={{ p: 4, maxWidth: 400, width: '100%' }}>
|
||||
<Box textAlign="center" mb={3}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Willkommen bei FibDash
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
Bitte melden Sie sich mit Ihrem Google-Konto an, um fortzufahren
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={this.handleGoogleLogin}
|
||||
disabled={loading}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{loading ? 'Anmeldung läuft...' : 'Mit Google anmelden'}
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
<Typography variant="caption" display="block" textAlign="center" sx={{ mt: 2 }}>
|
||||
Durch die Anmeldung stimmen Sie unseren Nutzungsbedingungen und Datenschutzrichtlinien zu.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Login;
|
||||
128
client/src/components/SummaryHeader.js
Normal file
128
client/src/components/SummaryHeader.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Grid,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
class SummaryHeader extends Component {
|
||||
formatAmount = (amount) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
getMonthName = (monthYear) => {
|
||||
if (!monthYear) return '';
|
||||
const [year, month] = monthYear.split('-');
|
||||
const date = new Date(year, month - 1);
|
||||
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
months,
|
||||
selectedMonth,
|
||||
summary,
|
||||
loading,
|
||||
onMonthChange,
|
||||
onDownloadDatev
|
||||
} = this.props;
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<Paper elevation={1} sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
|
||||
<Grid container alignItems="center" spacing={{ xs: 1, sm: 2 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Monat</InputLabel>
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onChange={onMonthChange}
|
||||
label="Month"
|
||||
>
|
||||
{months.map((month) => (
|
||||
<MenuItem key={month} value={month}>
|
||||
{this.getMonthName(month)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Transaktionen</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#1976d2', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{summary.totalTransactions}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Einnahmen</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{this.formatAmount(summary.totalIncome)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Ausgaben</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#d32f2f', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{this.formatAmount(summary.totalExpenses)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Nettobetrag</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: summary.netAmount >= 0 ? '#388e3c' : '#d32f2f',
|
||||
fontSize: { xs: '0.9rem', sm: '1.25rem' }
|
||||
}}
|
||||
>
|
||||
{this.formatAmount(summary.netAmount)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={1}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">JTL ✓</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{summary.jtlMatches || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4} md={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={onDownloadDatev}
|
||||
disabled={!selectedMonth || loading}
|
||||
size="small"
|
||||
sx={{ height: 'fit-content' }}
|
||||
>
|
||||
DATEV Export
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SummaryHeader;
|
||||
148
client/src/components/TransactionsTable.js
Normal file
148
client/src/components/TransactionsTable.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
class TransactionsTable extends Component {
|
||||
formatAmount = (amount) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const parts = dateString.split('.');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[0]}.${parts[1]}.20${parts[2]}`;
|
||||
}
|
||||
return dateString;
|
||||
};
|
||||
|
||||
getMonthName = (monthYear) => {
|
||||
if (!monthYear) return '';
|
||||
const [year, month] = monthYear.split('-');
|
||||
const date = new Date(year, month - 1);
|
||||
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { transactions, selectedMonth, loading } = this.props;
|
||||
|
||||
return (
|
||||
<Paper elevation={2}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
Transaktionen für {this.getMonthName(selectedMonth)}
|
||||
</Typography>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer sx={{ maxHeight: 700 }}>
|
||||
<Table stickyHeader size="small" sx={{ '& .MuiTableCell-root': { padding: '4px 8px', fontSize: '0.75rem', borderBottom: '1px solid #e0e0e0' } }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& .MuiTableCell-root': { backgroundColor: '#f5f5f5', fontWeight: 600 } }}>
|
||||
<TableCell sx={{ width: 80 }}>Datum</TableCell>
|
||||
<TableCell sx={{ width: 320 }}>Beschreibung</TableCell>
|
||||
<TableCell sx={{ width: 180 }}>Empfänger/Zahler</TableCell>
|
||||
<TableCell align="right" sx={{ width: 100 }}>Betrag</TableCell>
|
||||
<TableCell sx={{ width: 50 }}>Typ</TableCell>
|
||||
<TableCell sx={{ width: 50 }}>JTL</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{transactions.map((transaction, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
hover
|
||||
sx={{
|
||||
'&:hover': { backgroundColor: transaction.isJTLOnly ? '#ffebee' : '#f9f9f9' },
|
||||
'& .MuiTableCell-root': {
|
||||
padding: '2px 8px',
|
||||
backgroundColor: transaction.isJTLOnly ? '#ffebee' : 'inherit',
|
||||
borderLeft: transaction.isJTLOnly ? '4px solid #f44336' : 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
{this.formatDate(transaction['Buchungstag'])}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.7rem', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{transaction['Verwendungszweck'] || transaction['Buchungstext']}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.7rem', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{transaction['Beguenstigter/Zahlungspflichtiger']}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: transaction.numericAmount >= 0 ? '#388e3c' : '#d32f2f',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{this.formatAmount(transaction.numericAmount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: transaction.numericAmount >= 0 ? '#388e3c' : '#d32f2f',
|
||||
margin: 'auto'
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: transaction.hasJTL ? '#388e3c' : '#f5f5f5',
|
||||
border: transaction.hasJTL ? 'none' : '1px solid #ccc',
|
||||
margin: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{transaction.hasJTL && (
|
||||
<Box sx={{
|
||||
fontSize: '8px',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
✓
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TransactionsTable;
|
||||
6
client/src/index.js
Normal file
6
client/src/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
84
client/src/services/AuthService.js
Normal file
84
client/src/services/AuthService.js
Normal file
@@ -0,0 +1,84 @@
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.baseURL = '/api';
|
||||
}
|
||||
|
||||
async googleLogin(tokenResponse) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/auth/google`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: tokenResponse.credential || tokenResponse.access_token,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.log('Server error response:', errorData);
|
||||
const errorMessage = errorData.message || errorData.error || `HTTP ${response.status}: Login failed`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyToken(token) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/auth/verify`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.user;
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async apiCall(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
},
|
||||
};
|
||||
|
||||
const mergedOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, mergedOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Token is invalid or user is no longer authorized
|
||||
localStorage.removeItem('token');
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthService;
|
||||
57
debug-login.html
Normal file
57
debug-login.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FibDash Login Debug</title>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FibDash Login Debug</h1>
|
||||
|
||||
<div id="status">Initializing...</div>
|
||||
|
||||
<div id="g_id_onload"
|
||||
data-client_id="928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com"
|
||||
data-callback="handleCredentialResponse">
|
||||
</div>
|
||||
|
||||
<div class="g_id_signin" data-type="standard"></div>
|
||||
|
||||
<script>
|
||||
function handleCredentialResponse(response) {
|
||||
console.log('Google credential response:', response);
|
||||
document.getElementById('status').innerHTML = 'Got Google token, sending to server...';
|
||||
|
||||
// Send to our backend
|
||||
fetch('/api/auth/google', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: response.credential
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
console.log('Server response status:', response.status);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Server response data:', data);
|
||||
if (data.success) {
|
||||
document.getElementById('status').innerHTML = '✅ Login successful! User: ' + data.user.email;
|
||||
} else {
|
||||
document.getElementById('status').innerHTML = '❌ Login failed: ' + (data.message || data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Login error:', error);
|
||||
document.getElementById('status').innerHTML = '❌ Network error: ' + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
document.getElementById('status').innerHTML = 'Ready - Click "Sign in with Google"';
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
56
docker-compose.dev.yml
Normal file
56
docker-compose.dev.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./logs/nginx:/var/log/nginx
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- fibdash-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev.frontend
|
||||
ports:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- ./client:/app/client
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
networks:
|
||||
- fibdash-network
|
||||
command: npm run dev:frontend
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev.backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- fibdash-network
|
||||
command: npm run dev:backend
|
||||
|
||||
networks:
|
||||
fibdash-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
165
docs/GOOGLE_OAUTH_SETUP.md
Normal file
165
docs/GOOGLE_OAUTH_SETUP.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Google OAuth Setup Guide for FibDash
|
||||
|
||||
## Overview
|
||||
|
||||
This guide helps you set up Google OAuth correctly for your FibDash application, especially when deploying to a custom domain like `fibdash.growheads.de`.
|
||||
|
||||
## Common Errors
|
||||
|
||||
### 1. "Server did not send the correct CORS headers"
|
||||
This happens when your domain is not authorized in Google Cloud Console.
|
||||
|
||||
### 2. "Error retrieving a token" / "ERR_FAILED"
|
||||
This occurs when Google's servers can't reach your domain or the OAuth configuration is incorrect.
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Google Cloud Console Configuration
|
||||
|
||||
1. **Go to Google Cloud Console**
|
||||
- Visit: https://console.cloud.google.com/
|
||||
- Select your project or create a new one
|
||||
|
||||
2. **Enable Google+ API**
|
||||
- Navigate to "APIs & Services" > "Library"
|
||||
- Search for "Google+ API" and enable it
|
||||
- Also enable "Google Identity" if available
|
||||
|
||||
3. **Create OAuth 2.0 Credentials**
|
||||
- Go to "APIs & Services" > "Credentials"
|
||||
- Click "Create Credentials" > "OAuth 2.0 Client IDs"
|
||||
- Choose "Web application"
|
||||
|
||||
4. **Configure Authorized Origins**
|
||||
Add ALL domains you'll use:
|
||||
```
|
||||
http://localhost:5001
|
||||
http://localhost
|
||||
http://fibdash.growheads.de
|
||||
https://fibdash.growheads.de
|
||||
```
|
||||
|
||||
5. **Configure Authorized Redirect URIs**
|
||||
Add these URIs:
|
||||
```
|
||||
http://localhost:5001/
|
||||
http://localhost/
|
||||
http://fibdash.growheads.de/
|
||||
https://fibdash.growheads.de/
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
Update your `.env` file:
|
||||
|
||||
```env
|
||||
# Backend OAuth (for token verification)
|
||||
GOOGLE_CLIENT_ID=your_actual_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_actual_client_secret_here
|
||||
|
||||
# Frontend OAuth (for browser sign-in)
|
||||
REACT_APP_GOOGLE_CLIENT_ID=your_actual_client_id_here
|
||||
```
|
||||
|
||||
**Important**: Both `GOOGLE_CLIENT_ID` and `REACT_APP_GOOGLE_CLIENT_ID` should have the **same value**.
|
||||
|
||||
### 3. Domain-Specific Issues
|
||||
|
||||
#### For `fibdash.growheads.de`:
|
||||
|
||||
1. **DNS Configuration**
|
||||
- Ensure your domain points to the correct server
|
||||
- Test with: `nslookup fibdash.growheads.de`
|
||||
|
||||
2. **Nginx Configuration**
|
||||
- Make sure nginx is configured for your domain
|
||||
- Check server_name includes your domain:
|
||||
```nginx
|
||||
server_name localhost fibdash.local fibdash.growheads.de;
|
||||
```
|
||||
|
||||
3. **SSL/HTTPS (Recommended)**
|
||||
- Google OAuth works better with HTTPS
|
||||
- Consider using Let's Encrypt for free SSL certificates
|
||||
|
||||
### 4. Testing Your Setup
|
||||
|
||||
#### Test 1: Check OAuth Configuration
|
||||
```bash
|
||||
# Test if Google can reach your callback URL
|
||||
curl -I http://fibdash.growheads.de/
|
||||
```
|
||||
|
||||
#### Test 2: Verify Environment Variables
|
||||
Add this to your Login component temporarily:
|
||||
```javascript
|
||||
console.log('Google Client ID:', process.env.REACT_APP_GOOGLE_CLIENT_ID);
|
||||
```
|
||||
|
||||
#### Test 3: Check Browser Console
|
||||
Look for these specific errors:
|
||||
- CORS errors → Domain not authorized
|
||||
- Network errors → DNS/server issues
|
||||
- CSP errors → Content Security Policy too strict
|
||||
|
||||
### 5. Troubleshooting Commands
|
||||
|
||||
```bash
|
||||
# Restart your application
|
||||
npm run dev
|
||||
|
||||
# Check nginx configuration
|
||||
sudo nginx -t
|
||||
|
||||
# Reload nginx
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# Check if your domain resolves
|
||||
ping fibdash.growheads.de
|
||||
|
||||
# Check if your server is accessible
|
||||
curl -v http://fibdash.growheads.de/api/health
|
||||
```
|
||||
|
||||
## Alternative Solutions
|
||||
|
||||
### 1. Use the Fallback Button
|
||||
The app includes an "Alternative Google Sign-In" button that uses the older Google API, which sometimes works when the new GSI fails.
|
||||
|
||||
### 2. Temporary Localhost Testing
|
||||
If domain issues persist, test with localhost first:
|
||||
1. Update Google Cloud Console to only include localhost
|
||||
2. Test with `http://localhost:5001`
|
||||
3. Once working, add your domain back
|
||||
|
||||
### 3. Use Different OAuth Flow
|
||||
Consider implementing a server-side OAuth flow if client-side continues to fail.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Content Security Policy
|
||||
The nginx configuration includes CSP headers that allow Google domains:
|
||||
```nginx
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com ...";
|
||||
```
|
||||
|
||||
### CORS Headers
|
||||
For development, CORS is relaxed. In production, tighten these restrictions.
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Google Cloud Console configured with production domain
|
||||
- [ ] Environment variables set correctly
|
||||
- [ ] Nginx configured with your domain
|
||||
- [ ] SSL certificate installed (recommended)
|
||||
- [ ] CSP headers allow Google domains
|
||||
- [ ] Test OAuth flow end-to-end
|
||||
- [ ] Monitor for CORS errors in production
|
||||
|
||||
## Support
|
||||
|
||||
If you continue to have issues:
|
||||
1. Check the browser's developer console for specific error messages
|
||||
2. Verify your Google Cloud Console settings match exactly
|
||||
3. Test with a simple HTML page first to isolate the issue
|
||||
4. Consider using the server-side OAuth flow as an alternative
|
||||
109
nginx.dev.conf
Normal file
109
nginx.dev.conf
Normal file
@@ -0,0 +1,109 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost fibdash.local fibdash.growheads.de;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# Relaxed CSP for development with Google Sign-In
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com https://www.google.com https://ssl.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://accounts.google.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: https://accounts.google.com https://ssl.gstatic.com; connect-src 'self' https://accounts.google.com https://www.googleapis.com; frame-src https://accounts.google.com;";
|
||||
|
||||
# Proxy settings for WebSocket connections (for hot reload)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# API routes - proxy to Express backend
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_redirect off;
|
||||
|
||||
# CORS headers for development
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept";
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept";
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# WebSocket proxy for webpack-dev-server hot reload
|
||||
location /ws {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Webpack HMR specific endpoint
|
||||
location /__webpack_hmr {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Static assets and main application - proxy to webpack-dev-server
|
||||
location / {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_redirect off;
|
||||
|
||||
# Enable hot module replacement
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Handle WebSocket upgrade for HMR
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Disable caching for development
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/fibdash_access.log;
|
||||
error_log /var/log/nginx/fibdash_error.log warn;
|
||||
}
|
||||
|
||||
# WebSocket upgrade map
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
121
nginx.prod.conf
Normal file
121
nginx.prod.conf
Normal file
@@ -0,0 +1,121 @@
|
||||
# Production Nginx Configuration for FibDash
|
||||
# Single backend server serving both API and static files
|
||||
|
||||
upstream fibdash_backend {
|
||||
server 127.0.0.1:5000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name fibdash.local fibdash.growheads.de your-domain.com;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://accounts.google.com https://apis.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://accounts.google.com;";
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Static assets with long-term caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://fibdash_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Cache static assets for 1 year
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
# Optional: serve from nginx cache
|
||||
proxy_cache_valid 200 1y;
|
||||
proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
|
||||
}
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
proxy_pass http://fibdash_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# API responses shouldn't be cached
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
|
||||
# Increase proxy timeouts for API calls
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
proxy_pass http://fibdash_backend;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# All other requests (React app)
|
||||
location / {
|
||||
proxy_pass http://fibdash_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Cache HTML for a short time
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/fibdash_prod_access.log;
|
||||
error_log /var/log/nginx/fibdash_prod_error.log warn;
|
||||
}
|
||||
|
||||
# Optional: HTTPS redirect
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name your-domain.com;
|
||||
# return 301 https://$server_name$request_uri;
|
||||
# }
|
||||
|
||||
# Optional: HTTPS configuration
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /path/to/your/certificate.crt;
|
||||
# ssl_certificate_key /path/to/your/private.key;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
#
|
||||
# # ... rest of configuration same as above
|
||||
# }
|
||||
51
nginx.simple.conf
Normal file
51
nginx.simple.conf
Normal file
@@ -0,0 +1,51 @@
|
||||
# Simple Nginx Configuration for FibDash Development
|
||||
# Place this in /etc/nginx/sites-available/fibdash-dev
|
||||
|
||||
upstream frontend {
|
||||
server 127.0.0.1:5001;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost fibdash.local;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/fibdash_access.log;
|
||||
error_log /var/log/nginx/fibdash_error.log;
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support for HMR
|
||||
location /ws {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# All other requests to frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support for HMR
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
8
nodemon.json
Normal file
8
nodemon.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"watch": ["src/"],
|
||||
"ignore": ["client/", "dist/", "node_modules/", "*.test.js"],
|
||||
"ext": "js,json",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
9886
package-lock.json
generated
Normal file
9886
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "fibdash",
|
||||
"version": "1.0.0",
|
||||
"description": "React MUI webapp with Google SSO and MSSQL API",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
"dev:frontend": "webpack serve --mode development --config webpack.config.js",
|
||||
"dev:backend": "nodemon src/index.js",
|
||||
"build": "webpack --config webpack.prod.config.js",
|
||||
"build:prod": "npm run build && npm run start:prod",
|
||||
"start": "npm run build && node src/index.js",
|
||||
"start:prod": "NODE_ENV=production node src/index.js",
|
||||
"setup:nginx": "./scripts/setup-nginx-dev.sh",
|
||||
"nginx:test": "sudo nginx -t",
|
||||
"nginx:reload": "sudo systemctl reload nginx",
|
||||
"nginx:start": "sudo systemctl start nginx",
|
||||
"nginx:stop": "sudo systemctl stop nginx",
|
||||
"nginx:status": "sudo systemctl status nginx",
|
||||
"test:auth": "node test-auth.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.0",
|
||||
"@mui/material": "^5.14.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"google-auth-library": "^9.0.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"mssql": "^9.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.0",
|
||||
"@babel/preset-env": "^7.22.0",
|
||||
"@babel/preset-react": "^7.22.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.1",
|
||||
"babel-loader": "^9.1.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"css-loader": "^6.8.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"nodemon": "^3.0.0",
|
||||
"react-refresh": "^0.17.0",
|
||||
"style-loader": "^3.3.0",
|
||||
"webpack": "^5.88.0",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-dev-server": "^4.15.0"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
"mui",
|
||||
"google-sso",
|
||||
"mssql"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
71
scripts/setup-nginx-dev.sh
Executable file
71
scripts/setup-nginx-dev.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# FibDash Nginx Development Setup Script
|
||||
|
||||
echo "🚀 Setting up Nginx for FibDash development..."
|
||||
|
||||
# Create logs directory
|
||||
mkdir -p logs/nginx
|
||||
|
||||
# Check if nginx is installed
|
||||
if ! command -v nginx &> /dev/null; then
|
||||
echo "❌ Nginx is not installed. Please install nginx first:"
|
||||
echo " Ubuntu/Debian: sudo apt-get install nginx"
|
||||
echo " CentOS/RHEL: sudo yum install nginx"
|
||||
echo " macOS: brew install nginx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup existing nginx configuration
|
||||
if [ -f /etc/nginx/sites-available/default ]; then
|
||||
echo "📋 Backing up existing nginx configuration..."
|
||||
sudo cp /etc/nginx/sites-available/default /etc/nginx/sites-available/default.backup.$(date +%Y%m%d_%H%M%S)
|
||||
fi
|
||||
|
||||
# Copy our nginx configuration
|
||||
echo "📝 Installing FibDash nginx configuration..."
|
||||
sudo cp nginx.dev.conf /etc/nginx/sites-available/fibdash-dev
|
||||
sudo ln -sf /etc/nginx/sites-available/fibdash-dev /etc/nginx/sites-enabled/fibdash-dev
|
||||
|
||||
# Remove default site if it exists
|
||||
if [ -f /etc/nginx/sites-enabled/default ]; then
|
||||
echo "🗑️ Removing default nginx site..."
|
||||
sudo rm /etc/nginx/sites-enabled/default
|
||||
fi
|
||||
|
||||
# Test nginx configuration
|
||||
echo "🧪 Testing nginx configuration..."
|
||||
sudo nginx -t
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Nginx configuration is valid"
|
||||
|
||||
# Reload nginx
|
||||
echo "🔄 Reloading nginx..."
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# Enable nginx to start on boot
|
||||
sudo systemctl enable nginx
|
||||
|
||||
echo ""
|
||||
echo "🎉 Nginx setup complete!"
|
||||
echo ""
|
||||
echo "📋 Setup Summary:"
|
||||
echo " • Nginx config: /etc/nginx/sites-available/fibdash-dev"
|
||||
echo " • Logs directory: ./logs/nginx/"
|
||||
echo " • Frontend (via nginx): http://localhost/"
|
||||
echo " • API (via nginx): http://localhost/api/"
|
||||
echo " • Direct frontend: http://localhost:5001/"
|
||||
echo " • Direct backend: http://localhost:5000/"
|
||||
echo ""
|
||||
echo "🚀 Next steps:"
|
||||
echo " 1. Start your development servers: npm run dev"
|
||||
echo " 2. Access your app at: http://localhost/"
|
||||
echo ""
|
||||
echo "💡 To add fibdash.local to your hosts file:"
|
||||
echo " echo '127.0.0.1 fibdash.local' | sudo tee -a /etc/hosts"
|
||||
|
||||
else
|
||||
echo "❌ Nginx configuration test failed. Please check the configuration."
|
||||
exit 1
|
||||
fi
|
||||
82
src/config/database.js
Normal file
82
src/config/database.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const sql = require('mssql');
|
||||
|
||||
const config = {
|
||||
server: process.env.DB_SERVER,
|
||||
database: process.env.DB_DATABASE,
|
||||
user: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: parseInt(process.env.DB_PORT) || 1433,
|
||||
options: {
|
||||
encrypt: false, // Disable encryption to avoid TLS warnings with IP addresses
|
||||
trustServerCertificate: true,
|
||||
enableArithAbort: true,
|
||||
},
|
||||
pool: {
|
||||
max: 10,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
let poolPromise;
|
||||
|
||||
const getPool = () => {
|
||||
if (!poolPromise) {
|
||||
poolPromise = new sql.ConnectionPool(config).connect().then(pool => {
|
||||
console.log('✅ Connected to MSSQL database');
|
||||
return pool;
|
||||
}).catch(err => {
|
||||
console.error('❌ Database connection failed:', err);
|
||||
poolPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return poolPromise;
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
if (!process.env.DB_SERVER) {
|
||||
console.log('⚠️ Database configuration not found. Application will run without database.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query('SELECT 1 as test');
|
||||
console.log('✅ Database connection test successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection test failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const executeQuery = async (query, params = {}) => {
|
||||
if (!process.env.DB_SERVER) {
|
||||
throw new Error('Database not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const pool = await getPool();
|
||||
const request = pool.request();
|
||||
|
||||
// Add parameters to the request
|
||||
Object.keys(params).forEach(key => {
|
||||
request.input(key, params[key]);
|
||||
});
|
||||
|
||||
const result = await request.query(query);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Database query error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
config,
|
||||
getPool,
|
||||
testConnection,
|
||||
executeQuery,
|
||||
sql,
|
||||
};
|
||||
36
src/database/schema.sql
Normal file
36
src/database/schema.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- FibDash Database Schema
|
||||
-- Run these commands in your MSSQL database
|
||||
|
||||
-- Create Users table
|
||||
CREATE TABLE Users (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
google_id NVARCHAR(255) UNIQUE NOT NULL,
|
||||
email NVARCHAR(255) UNIQUE NOT NULL,
|
||||
name NVARCHAR(255) NOT NULL,
|
||||
picture NVARCHAR(500),
|
||||
created_at DATETIME2 DEFAULT GETDATE(),
|
||||
last_login DATETIME2,
|
||||
is_active BIT DEFAULT 1
|
||||
);
|
||||
|
||||
-- Create UserPreferences table
|
||||
CREATE TABLE UserPreferences (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
theme NVARCHAR(50) DEFAULT 'light',
|
||||
language NVARCHAR(10) DEFAULT 'en',
|
||||
notifications_enabled BIT DEFAULT 1,
|
||||
created_at DATETIME2 DEFAULT GETDATE(),
|
||||
updated_at DATETIME2 DEFAULT GETDATE(),
|
||||
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IX_Users_Email ON Users(email);
|
||||
CREATE INDEX IX_Users_GoogleId ON Users(google_id);
|
||||
CREATE INDEX IX_UserPreferences_UserId ON UserPreferences(user_id);
|
||||
|
||||
-- Insert sample data (optional)
|
||||
-- Note: This will only work after you have real Google user data
|
||||
-- INSERT INTO Users (google_id, email, name, picture)
|
||||
-- VALUES ('sample_google_id', 'user@example.com', 'Lorem Ipsum User', 'https://example.com/picture.jpg');
|
||||
78
src/index.js
Normal file
78
src/index.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const dataRoutes = require('./routes/data');
|
||||
const dbConfig = require('./config/database');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/data', dataRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Debug login page (development only)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.get('/debug-login', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../debug-login.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Serve static files in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// Serve static files from dist directory
|
||||
app.use(express.static(path.join(__dirname, '../dist'), {
|
||||
maxAge: '1y', // Cache static assets for 1 year
|
||||
etag: true,
|
||||
}));
|
||||
|
||||
// Handle client-side routing - serve index.html for all non-API routes
|
||||
app.get('*', (req, res, next) => {
|
||||
// Skip API routes
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return next();
|
||||
}
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
});
|
||||
|
||||
console.log('📦 Serving static files from dist/ directory');
|
||||
} else {
|
||||
console.log('🔧 Development mode - static files served by webpack-dev-server');
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📊 Dashboard: http://localhost:3000`);
|
||||
console.log(`🔗 API: http://localhost:${PORT}/api`);
|
||||
|
||||
// Test database connection
|
||||
const { testConnection } = require('./config/database');
|
||||
testConnection();
|
||||
});
|
||||
46
src/middleware/auth.js
Normal file
46
src/middleware/auth.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEmailAuthorized } = require('./emailAuth');
|
||||
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret', (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Additional check: ensure the user's email is still authorized
|
||||
if (!isEmailAuthorized(user.email)) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Your email address is no longer authorized to access this application'
|
||||
});
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const generateToken = (user) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.picture,
|
||||
},
|
||||
process.env.JWT_SECRET || 'fallback_secret',
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
generateToken,
|
||||
};
|
||||
48
src/middleware/emailAuth.js
Normal file
48
src/middleware/emailAuth.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const checkAuthorizedEmail = (req, res, next) => {
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||
|
||||
// If no authorized emails are configured, deny all users
|
||||
if (!authorizedEmails || authorizedEmails.trim() === '') {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'No authorized users configured. Contact administrator.'
|
||||
});
|
||||
}
|
||||
|
||||
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
|
||||
const userEmail = req.user?.email?.toLowerCase();
|
||||
|
||||
if (!userEmail) {
|
||||
return res.status(401).json({
|
||||
error: 'User email not found',
|
||||
message: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!emailList.includes(userEmail)) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Your email address is not authorized to access this application'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
const isEmailAuthorized = (email) => {
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||
|
||||
// If no authorized emails are configured, deny all users
|
||||
if (!authorizedEmails || authorizedEmails.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailList = authorizedEmails.split(',').map(e => e.trim().toLowerCase());
|
||||
const userEmail = email.toLowerCase();
|
||||
return emailList.includes(userEmail);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
checkAuthorizedEmail,
|
||||
isEmailAuthorized,
|
||||
};
|
||||
110
src/routes/admin.js
Normal file
110
src/routes/admin.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const express = require('express');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Check if user is admin (first email in the list or specific admin email)
|
||||
const checkAdminAccess = (req, res, next) => {
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||
if (!authorizedEmails || authorizedEmails.trim() === '') {
|
||||
return res.status(403).json({ error: 'No authorized emails configured' });
|
||||
}
|
||||
|
||||
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
|
||||
const userEmail = req.user?.email?.toLowerCase();
|
||||
|
||||
// First email in the list is considered admin, or check for specific admin emails
|
||||
const adminEmails = [emailList[0]]; // First email is admin
|
||||
|
||||
if (!adminEmails.includes(userEmail)) {
|
||||
return res.status(403).json({
|
||||
error: 'Admin access required',
|
||||
message: 'Only administrators can access this resource'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Get current authorized emails (admin only)
|
||||
router.get('/authorized-emails', authenticateToken, checkAdminAccess, (req, res) => {
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
|
||||
if (!authorizedEmails) {
|
||||
return res.json({ emails: [] });
|
||||
}
|
||||
|
||||
const emailList = authorizedEmails.split(',').map(email => email.trim());
|
||||
res.json({ emails: emailList });
|
||||
});
|
||||
|
||||
// Add authorized email (admin only)
|
||||
router.post('/authorized-emails', authenticateToken, checkAdminAccess, (req, res) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return res.status(400).json({ error: 'Valid email address required' });
|
||||
}
|
||||
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS || '';
|
||||
const emailList = authorizedEmails.split(',').map(e => e.trim()).filter(e => e);
|
||||
|
||||
const newEmail = email.trim().toLowerCase();
|
||||
if (emailList.map(e => e.toLowerCase()).includes(newEmail)) {
|
||||
return res.status(400).json({ error: 'Email already authorized' });
|
||||
}
|
||||
|
||||
emailList.push(email.trim());
|
||||
|
||||
// Note: This only updates the runtime environment variable
|
||||
// For persistent changes, you'd need to update the .env file
|
||||
process.env.AUTHORIZED_EMAILS = emailList.join(',');
|
||||
|
||||
res.json({
|
||||
message: 'Email added successfully',
|
||||
emails: emailList,
|
||||
note: 'Changes are temporary. Update .env file for permanent changes.'
|
||||
});
|
||||
});
|
||||
|
||||
// Remove authorized email (admin only)
|
||||
router.delete('/authorized-emails/:email', authenticateToken, checkAdminAccess, (req, res) => {
|
||||
const emailToRemove = req.params.email.toLowerCase();
|
||||
const authorizedEmails = process.env.AUTHORIZED_EMAILS || '';
|
||||
const emailList = authorizedEmails.split(',').map(e => e.trim()).filter(e => e);
|
||||
|
||||
const filteredEmails = emailList.filter(email => email.toLowerCase() !== emailToRemove);
|
||||
|
||||
if (filteredEmails.length === emailList.length) {
|
||||
return res.status(404).json({ error: 'Email not found in authorized list' });
|
||||
}
|
||||
|
||||
// Don't allow removing the last admin email
|
||||
if (filteredEmails.length === 0) {
|
||||
return res.status(400).json({ error: 'Cannot remove all authorized emails' });
|
||||
}
|
||||
|
||||
// Note: This only updates the runtime environment variable
|
||||
process.env.AUTHORIZED_EMAILS = filteredEmails.join(',');
|
||||
|
||||
res.json({
|
||||
message: 'Email removed successfully',
|
||||
emails: filteredEmails,
|
||||
note: 'Changes are temporary. Update .env file for permanent changes.'
|
||||
});
|
||||
});
|
||||
|
||||
// Get system info (admin only)
|
||||
router.get('/system-info', authenticateToken, checkAdminAccess, (req, res) => {
|
||||
res.json({
|
||||
authorizedEmailsConfigured: !!process.env.AUTHORIZED_EMAILS,
|
||||
totalAuthorizedEmails: process.env.AUTHORIZED_EMAILS ? process.env.AUTHORIZED_EMAILS.split(',').length : 0,
|
||||
currentUser: req.user.email,
|
||||
isAdmin: true,
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
133
src/routes/auth.js
Normal file
133
src/routes/auth.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const express = require('express');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
||||
const { executeQuery } = require('../config/database');
|
||||
const { isEmailAuthorized } = require('../middleware/emailAuth');
|
||||
|
||||
const router = express.Router();
|
||||
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
|
||||
|
||||
// Google OAuth login
|
||||
router.post('/google', async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
console.log('🔍 Login attempt with token:', token ? 'Present' : 'Missing');
|
||||
|
||||
if (!token) {
|
||||
console.log('❌ No token provided');
|
||||
return res.status(400).json({ error: 'Token is required' });
|
||||
}
|
||||
|
||||
// Verify Google token
|
||||
console.log('🔐 Verifying Google token...');
|
||||
const ticket = await client.verifyIdToken({
|
||||
idToken: token,
|
||||
audience: process.env.GOOGLE_CLIENT_ID,
|
||||
});
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
const googleId = payload['sub'];
|
||||
const email = payload['email'];
|
||||
const name = payload['name'];
|
||||
const picture = payload['picture'];
|
||||
|
||||
console.log(`👤 Google token verified for: ${email}`);
|
||||
|
||||
// Check if email is authorized
|
||||
const authorized = isEmailAuthorized(email);
|
||||
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
|
||||
|
||||
if (!authorized) {
|
||||
console.log(`❌ Access denied for ${email}`);
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Your email address is not authorized to access this application'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists in database (optional - auth works without DB)
|
||||
let user;
|
||||
try {
|
||||
// Only try database operations if DB is configured
|
||||
if (process.env.DB_SERVER) {
|
||||
const userResult = await executeQuery(
|
||||
'SELECT * FROM Users WHERE email = @email',
|
||||
{ email }
|
||||
);
|
||||
|
||||
if (userResult.recordset.length > 0) {
|
||||
// User exists, update last login
|
||||
user = userResult.recordset[0];
|
||||
await executeQuery(
|
||||
'UPDATE Users SET last_login = GETDATE(), picture = @picture WHERE id = @id',
|
||||
{ picture, id: user.id }
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
const insertResult = await executeQuery(
|
||||
`INSERT INTO Users (google_id, email, name, picture, created_at, last_login)
|
||||
OUTPUT INSERTED.*
|
||||
VALUES (@googleId, @email, @name, @picture, GETDATE(), GETDATE())`,
|
||||
{ googleId, email, name, picture }
|
||||
);
|
||||
user = insertResult.recordset[0];
|
||||
}
|
||||
console.log('✅ Database operations completed successfully');
|
||||
} else {
|
||||
console.log('⚠️ No database configured, using fallback user object');
|
||||
throw new Error('No database configured');
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('Database error during authentication:', dbError.message);
|
||||
// Fallback: create user object without database
|
||||
user = {
|
||||
id: googleId,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
google_id: googleId,
|
||||
};
|
||||
console.log('✅ Using fallback user object (no database)');
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const jwtToken = generateToken(user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token: jwtToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
picture: user.picture,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Google authentication error:', error);
|
||||
res.status(401).json({ error: 'Invalid Google token' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify JWT token
|
||||
router.get('/verify', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// Token is already verified by middleware
|
||||
res.json({
|
||||
success: true,
|
||||
user: req.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (client-side token removal)
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
103
src/routes/dashboard.js
Normal file
103
src/routes/dashboard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const express = require('express');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { executeQuery } = require('../config/database');
|
||||
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get dashboard data
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
let dashboardData = {
|
||||
stats: [
|
||||
{ title: 'Total Users', value: 'N/A', icon: 'PeopleIcon', color: '#1976d2' },
|
||||
{ title: 'Revenue', value: 'N/A', icon: 'TrendingUpIcon', color: '#388e3c' },
|
||||
{ title: 'Reports', value: 'N/A', icon: 'AssessmentIcon', color: '#f57c00' },
|
||||
{ title: 'Growth', value: 'N/A', icon: 'TimelineIcon', color: '#7b1fa2' },
|
||||
],
|
||||
recentActivity: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Only try database operations if configured
|
||||
if (process.env.DB_SERVER) {
|
||||
// Try to fetch real data from database
|
||||
const userCountResult = await executeQuery('SELECT COUNT(*) as count FROM Users');
|
||||
const userCount = userCountResult.recordset[0]?.count || 0;
|
||||
|
||||
// Update stats with real data
|
||||
dashboardData.stats[0].value = userCount.toString();
|
||||
|
||||
// Fetch recent activity
|
||||
const activityResult = await executeQuery(`
|
||||
SELECT TOP 10
|
||||
CONCAT('User ', name, ' logged in') as description,
|
||||
FORMAT(last_login, 'yyyy-MM-dd HH:mm') as timestamp
|
||||
FROM Users
|
||||
WHERE last_login IS NOT NULL
|
||||
ORDER BY last_login DESC
|
||||
`);
|
||||
|
||||
dashboardData.recentActivity = activityResult.recordset || [];
|
||||
console.log('✅ Dashboard data loaded from database');
|
||||
} else {
|
||||
console.log('⚠️ No database configured, using mock dashboard data');
|
||||
// Update with mock data
|
||||
dashboardData.stats[0].value = '1';
|
||||
dashboardData.stats[1].value = '$0';
|
||||
dashboardData.stats[2].value = '0';
|
||||
dashboardData.stats[3].value = '0%';
|
||||
dashboardData.recentActivity = [
|
||||
{ description: 'System started without database', timestamp: new Date().toISOString().slice(0, 16) }
|
||||
];
|
||||
}
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database query error in dashboard:', dbError.message);
|
||||
// Keep fallback data
|
||||
console.log('✅ Using fallback dashboard data');
|
||||
}
|
||||
|
||||
res.json(dashboardData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user-specific data
|
||||
router.get('/user', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
let userData = {
|
||||
profile: req.user,
|
||||
preferences: {},
|
||||
activity: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch user preferences from database
|
||||
const prefsResult = await executeQuery(
|
||||
'SELECT * FROM UserPreferences WHERE user_id = @userId',
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (prefsResult.recordset.length > 0) {
|
||||
userData.preferences = prefsResult.recordset[0];
|
||||
}
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database query error for user data:', dbError);
|
||||
}
|
||||
|
||||
res.json(userData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('User data error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user data' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
352
src/routes/data.js
Normal file
352
src/routes/data.js
Normal file
@@ -0,0 +1,352 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Parse CSV data
|
||||
const parseCSV = () => {
|
||||
try {
|
||||
const csvPath = path.join(__dirname, '../../data.csv');
|
||||
const csvData = fs.readFileSync(csvPath, 'utf8');
|
||||
const lines = csvData.split('\n');
|
||||
const headers = lines[0].split(';').map(h => h.replace(/"/g, ''));
|
||||
|
||||
const transactions = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// Parse CSV line (handle semicolon-separated values with quotes)
|
||||
const values = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j];
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ';' && !inQuotes) {
|
||||
values.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current); // Add last value
|
||||
|
||||
if (values.length >= headers.length) {
|
||||
const transaction = {};
|
||||
headers.forEach((header, index) => {
|
||||
transaction[header] = values[index] || '';
|
||||
});
|
||||
|
||||
// Parse date and amount
|
||||
if (transaction['Buchungstag']) {
|
||||
const dateParts = transaction['Buchungstag'].split('.');
|
||||
if (dateParts.length === 3) {
|
||||
// Convert DD.MM.YY to proper date
|
||||
const day = dateParts[0];
|
||||
const month = dateParts[1];
|
||||
const year = '20' + dateParts[2]; // Assuming 20xx
|
||||
transaction.parsedDate = new Date(year, month - 1, day);
|
||||
transaction.monthYear = `${year}-${month.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse amount
|
||||
if (transaction['Betrag']) {
|
||||
const amount = transaction['Betrag'].replace(',', '.').replace(/[^-0-9.]/g, '');
|
||||
transaction.numericAmount = parseFloat(amount) || 0;
|
||||
}
|
||||
|
||||
transactions.push(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
console.error('Error parsing CSV:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Get available months
|
||||
router.get('/months', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const transactions = parseCSV();
|
||||
const months = [...new Set(transactions
|
||||
.filter(t => t.monthYear)
|
||||
.map(t => t.monthYear)
|
||||
)].sort().reverse(); // Newest first
|
||||
|
||||
res.json({ months });
|
||||
} catch (error) {
|
||||
console.error('Error getting months:', error);
|
||||
res.status(500).json({ error: 'Failed to load months' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get database transactions for JTL comparison
|
||||
const getJTLTransactions = async () => {
|
||||
try {
|
||||
const { executeQuery } = require('../config/database');
|
||||
const query = `
|
||||
SELECT
|
||||
cKonto, cKontozusatz, cName, dBuchungsdatum,
|
||||
tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz,
|
||||
cVerwendungszweck, fBetrag, tUmsatzKontierung.data
|
||||
FROM [eazybusiness].[dbo].[tZahlungsabgleichUmsatz]
|
||||
LEFT JOIN tUmsatzKontierung ON (tUmsatzKontierung.kZahlungsabgleichUmsatz = tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz)
|
||||
ORDER BY dBuchungsdatum desc, tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz desc
|
||||
`;
|
||||
|
||||
const result = await executeQuery(query);
|
||||
return result.recordset || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching JTL transactions:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Get transactions for a specific month
|
||||
router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { monthYear } = req.params;
|
||||
const transactions = parseCSV();
|
||||
|
||||
const monthTransactions = transactions
|
||||
.filter(t => t.monthYear === monthYear)
|
||||
.sort((a, b) => b.parsedDate - a.parsedDate); // Newest first
|
||||
|
||||
// Get JTL transactions for comparison
|
||||
let jtlTransactions = [];
|
||||
try {
|
||||
jtlTransactions = await getJTLTransactions();
|
||||
} catch (error) {
|
||||
console.log('JTL database not available, continuing without JTL data');
|
||||
}
|
||||
|
||||
// Filter JTL transactions for the selected month
|
||||
const [year, month] = monthYear.split('-');
|
||||
const jtlMonthTransactions = jtlTransactions.filter(jtl => {
|
||||
const jtlDate = new Date(jtl.dBuchungsdatum);
|
||||
return jtlDate.getFullYear() === parseInt(year) &&
|
||||
jtlDate.getMonth() === parseInt(month) - 1;
|
||||
});
|
||||
|
||||
// Add JTL status to each CSV transaction
|
||||
const transactionsWithJTL = monthTransactions.map(transaction => {
|
||||
// Try to match by amount and date (approximate matching)
|
||||
const amount = transaction.numericAmount;
|
||||
const transactionDate = transaction.parsedDate;
|
||||
|
||||
const jtlMatch = jtlMonthTransactions.find(jtl => {
|
||||
const jtlAmount = parseFloat(jtl.fBetrag) || 0;
|
||||
const jtlDate = new Date(jtl.dBuchungsdatum);
|
||||
|
||||
// Match by amount (exact) and date (same day)
|
||||
const amountMatch = Math.abs(amount - jtlAmount) < 0.01;
|
||||
const dateMatch = transactionDate && jtlDate &&
|
||||
transactionDate.getFullYear() === jtlDate.getFullYear() &&
|
||||
transactionDate.getMonth() === jtlDate.getMonth() &&
|
||||
transactionDate.getDate() === jtlDate.getDate();
|
||||
|
||||
return amountMatch && dateMatch;
|
||||
});
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
hasJTL: !!jtlMatch,
|
||||
jtlId: jtlMatch ? jtlMatch.kZahlungsabgleichUmsatz : null,
|
||||
isFromCSV: true
|
||||
};
|
||||
});
|
||||
|
||||
// Find JTL transactions that don't have CSV matches (red rows)
|
||||
const unmatchedJTLTransactions = jtlMonthTransactions
|
||||
.filter(jtl => {
|
||||
const jtlAmount = parseFloat(jtl.fBetrag) || 0;
|
||||
const jtlDate = new Date(jtl.dBuchungsdatum);
|
||||
|
||||
// Check if this JTL transaction has a CSV match
|
||||
const hasCSVMatch = monthTransactions.some(transaction => {
|
||||
const amount = transaction.numericAmount;
|
||||
const transactionDate = transaction.parsedDate;
|
||||
|
||||
const amountMatch = Math.abs(amount - jtlAmount) < 0.01;
|
||||
const dateMatch = transactionDate && jtlDate &&
|
||||
transactionDate.getFullYear() === jtlDate.getFullYear() &&
|
||||
transactionDate.getMonth() === jtlDate.getMonth() &&
|
||||
transactionDate.getDate() === jtlDate.getDate();
|
||||
|
||||
return amountMatch && dateMatch;
|
||||
});
|
||||
|
||||
return !hasCSVMatch;
|
||||
})
|
||||
.map(jtl => ({
|
||||
// Convert JTL format to CSV-like format for display
|
||||
'Buchungstag': new Date(jtl.dBuchungsdatum).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit'
|
||||
}),
|
||||
'Verwendungszweck': jtl.cVerwendungszweck || '',
|
||||
'Buchungstext': 'JTL Transaction',
|
||||
'Beguenstigter/Zahlungspflichtiger': jtl.cName || '',
|
||||
'Betrag': jtl.fBetrag ? jtl.fBetrag.toString().replace('.', ',') : '0,00',
|
||||
numericAmount: parseFloat(jtl.fBetrag) || 0,
|
||||
parsedDate: new Date(jtl.dBuchungsdatum),
|
||||
monthYear: monthYear,
|
||||
hasJTL: true,
|
||||
jtlId: jtl.kZahlungsabgleichUmsatz,
|
||||
isFromCSV: false,
|
||||
isJTLOnly: true
|
||||
}));
|
||||
|
||||
// Combine CSV and JTL-only transactions
|
||||
const allTransactions = [...transactionsWithJTL, ...unmatchedJTLTransactions]
|
||||
.sort((a, b) => b.parsedDate - a.parsedDate);
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
totalTransactions: allTransactions.length,
|
||||
totalIncome: allTransactions
|
||||
.filter(t => t.numericAmount > 0)
|
||||
.reduce((sum, t) => sum + t.numericAmount, 0),
|
||||
totalExpenses: allTransactions
|
||||
.filter(t => t.numericAmount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
|
||||
netAmount: allTransactions.reduce((sum, t) => sum + t.numericAmount, 0),
|
||||
jtlMatches: allTransactions.filter(t => t.hasJTL && t.isFromCSV).length,
|
||||
jtlMissing: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length,
|
||||
jtlOnly: allTransactions.filter(t => t.isJTLOnly).length,
|
||||
csvOnly: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length
|
||||
};
|
||||
|
||||
res.json({
|
||||
transactions: allTransactions,
|
||||
summary,
|
||||
monthYear
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting transactions:', error);
|
||||
res.status(500).json({ error: 'Failed to load transactions' });
|
||||
}
|
||||
});
|
||||
|
||||
// DATEV export functionality
|
||||
const buildDatevHeader = (periodStart, periodEnd) => {
|
||||
const ts = new Date().toISOString().replace(/[-T:\.Z]/g, '').slice(0, 17); // yyyymmddHHMMSSfff
|
||||
const meta = {
|
||||
consultant: 1001,
|
||||
client: 10001,
|
||||
fyStart: periodStart.slice(0, 4) + '0101', // fiscal year start
|
||||
accLength: 4,
|
||||
description: 'Bank Statement Export',
|
||||
currency: 'EUR'
|
||||
};
|
||||
|
||||
return [
|
||||
'"EXTF"', 700, 21, '"Buchungsstapel"', 12, ts,
|
||||
'', '', '', '', // 7‑10 spare
|
||||
meta.consultant, meta.client, // 11, 12
|
||||
meta.fyStart, meta.accLength, // 13, 14
|
||||
periodStart, periodEnd, // 15, 16
|
||||
'"' + meta.description + '"',
|
||||
'AM', 1, 0, 1, meta.currency
|
||||
].join(';');
|
||||
};
|
||||
|
||||
const DATEV_COLS = [
|
||||
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz',
|
||||
'Kurs', 'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto',
|
||||
'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum',
|
||||
'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext',
|
||||
'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank',
|
||||
'Sachverhalt', 'Zinssperre', 'Beleglink'
|
||||
].join(';');
|
||||
|
||||
const formatDatevAmount = (amount) => {
|
||||
return Math.abs(amount).toFixed(2).replace('.', ',');
|
||||
};
|
||||
|
||||
const formatDatevDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const parts = dateString.split('.');
|
||||
if (parts.length === 3) {
|
||||
const day = parts[0].padStart(2, '0');
|
||||
const month = parts[1].padStart(2, '0');
|
||||
return day + month;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const quote = (str, maxLen = 60) => {
|
||||
if (!str) return '""';
|
||||
return '"' + str.slice(0, maxLen).replace(/"/g, '""') + '"';
|
||||
};
|
||||
|
||||
// DATEV export endpoint
|
||||
router.get('/datev/:monthYear', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { monthYear } = req.params;
|
||||
const [year, month] = monthYear.split('-');
|
||||
|
||||
// Get transactions for the month
|
||||
const transactions = parseCSV();
|
||||
const monthTransactions = transactions
|
||||
.filter(t => t.monthYear === monthYear)
|
||||
.sort((a, b) => a.parsedDate - b.parsedDate); // Oldest first for DATEV
|
||||
|
||||
if (!monthTransactions.length) {
|
||||
return res.status(404).json({ error: 'No transactions found for this month' });
|
||||
}
|
||||
|
||||
// Build DATEV format
|
||||
const periodStart = `${year}${month.padStart(2, '0')}01`;
|
||||
const periodEnd = new Date(year, month, 0).toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
const header = buildDatevHeader(periodStart, periodEnd);
|
||||
|
||||
const rows = monthTransactions.map((transaction, index) => {
|
||||
const amount = Math.abs(transaction.numericAmount);
|
||||
const isDebit = transaction.numericAmount < 0 ? 'S' : 'H'; // S = Soll (debit), H = Haben (credit)
|
||||
|
||||
return [
|
||||
formatDatevAmount(amount), // #1 Umsatz
|
||||
isDebit, // #2 Soll/Haben
|
||||
quote('EUR', 3), // #3 WKZ Umsatz
|
||||
'', '', '', // #4-6 (no FX)
|
||||
'1200', // #7 Konto (Bank account)
|
||||
transaction.numericAmount < 0 ? '4000' : '8400', // #8 Gegenkonto (expense/income)
|
||||
'', // #9 BU-Schlüssel
|
||||
formatDatevDate(transaction['Buchungstag']), // #10 Belegdatum
|
||||
quote((index + 1).toString(), 36), // #11 Belegfeld 1 (sequential number)
|
||||
'', '', // #12, #13
|
||||
quote(transaction['Verwendungszweck'] || transaction['Buchungstext'], 60), // #14 Buchungstext
|
||||
'', '', '', '', '', // #15-19 unused
|
||||
'' // #20 Beleglink
|
||||
].join(';');
|
||||
});
|
||||
|
||||
const csv = [header, DATEV_COLS, ...rows].join('\r\n');
|
||||
|
||||
// Set headers for file download
|
||||
const filename = `DATEV_${year}_${month.padStart(2, '0')}.csv`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=latin1');
|
||||
|
||||
// Convert to latin1 encoding for DATEV compatibility
|
||||
const buffer = Buffer.from(csv, 'utf8');
|
||||
res.send(buffer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating DATEV export:', error);
|
||||
res.status(500).json({ error: 'Failed to generate DATEV export' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
53
test-auth.js
Executable file
53
test-auth.js
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Simple test script to verify email authorization
|
||||
require('dotenv').config();
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
console.log('🧪 Testing Email Authorization System\n');
|
||||
|
||||
// Test data
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret';
|
||||
const AUTHORIZED_EMAILS = process.env.AUTHORIZED_EMAILS || '';
|
||||
|
||||
console.log(`Authorized emails: ${AUTHORIZED_EMAILS || 'None (all users allowed)'}`);
|
||||
|
||||
// Test cases
|
||||
const testUsers = [
|
||||
{ email: 'admin@example.com', name: 'Admin User' },
|
||||
{ email: 'user1@example.com', name: 'User One' },
|
||||
{ email: 'unauthorized@hacker.com', name: 'Unauthorized User' },
|
||||
];
|
||||
|
||||
// Import the authorization function
|
||||
const { isEmailAuthorized } = require('./src/middleware/emailAuth');
|
||||
|
||||
console.log('\n📋 Authorization Test Results:\n');
|
||||
|
||||
testUsers.forEach(user => {
|
||||
const isAuthorized = isEmailAuthorized(user.email);
|
||||
const status = isAuthorized ? '✅ AUTHORIZED' : '❌ DENIED';
|
||||
console.log(`${status} - ${user.name} (${user.email})`);
|
||||
|
||||
if (isAuthorized) {
|
||||
// Create a JWT token for authorized users
|
||||
const token = jwt.sign(
|
||||
{ id: 'test', email: user.email, name: user.name },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
console.log(` Token: ${token.substring(0, 50)}...`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n🔐 Security Check:');
|
||||
console.log(`- Email authorization: ${AUTHORIZED_EMAILS && AUTHORIZED_EMAILS.trim() !== '' ? 'ENABLED' : 'DISABLED'}`);
|
||||
console.log(`- JWT verification: ENABLED`);
|
||||
console.log(`- API endpoint protection: ENABLED`);
|
||||
|
||||
if (!AUTHORIZED_EMAILS || AUTHORIZED_EMAILS.trim() === '') {
|
||||
console.log('\n🛡️ SECURITY: No authorized emails configured. ALL USERS DENIED ACCESS.');
|
||||
console.log(' Set AUTHORIZED_EMAILS in your .env file to allow specific users.');
|
||||
}
|
||||
|
||||
console.log('\n✅ Authorization system test complete!');
|
||||
78
webpack.config.js
Normal file
78
webpack.config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
entry: './client/src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
publicPath: '/',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env', '@babel/preset-react'],
|
||||
plugins: [
|
||||
process.env.NODE_ENV === 'development' && require.resolve('react-refresh/babel')
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: './client/public/index.html',
|
||||
templateParameters: {
|
||||
REACT_APP_GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || 'your_google_client_id_here',
|
||||
},
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
REACT_APP_GOOGLE_CLIENT_ID: JSON.stringify(process.env.GOOGLE_CLIENT_ID),
|
||||
},
|
||||
}),
|
||||
new ReactRefreshWebpackPlugin(),
|
||||
],
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.join(__dirname, 'client/public'),
|
||||
},
|
||||
compress: true,
|
||||
port: 5001,
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
allowedHosts: 'all',
|
||||
host: '0.0.0.0',
|
||||
client: {
|
||||
webSocketURL: 'auto://0.0.0.0:0/ws',
|
||||
},
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
};
|
||||
79
webpack.prod.config.js
Normal file
79
webpack.prod.config.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
entry: './client/src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'js/[name].[contenthash].js',
|
||||
chunkFilename: 'js/[name].[contenthash].chunk.js',
|
||||
publicPath: '/',
|
||||
clean: true, // Clean dist folder before build
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env', '@babel/preset-react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: './client/public/index.html',
|
||||
templateParameters: {
|
||||
REACT_APP_GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || 'your_google_client_id_here',
|
||||
},
|
||||
minify: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeRedundantAttributes: true,
|
||||
useShortDoctype: true,
|
||||
removeEmptyAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
keepClosingSlash: true,
|
||||
minifyJS: true,
|
||||
minifyCSS: true,
|
||||
minifyURLs: true,
|
||||
},
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify('production'),
|
||||
REACT_APP_GOOGLE_CLIENT_ID: JSON.stringify(process.env.GOOGLE_CLIENT_ID),
|
||||
},
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
vendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: 'vendors',
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
performance: {
|
||||
maxAssetSize: 512000,
|
||||
maxEntrypointSize: 512000,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user