This commit is contained in:
sebseb7
2025-07-19 21:58:07 +02:00
commit 102a4ec9ff
37 changed files with 14258 additions and 0 deletions

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
node_modules

363
README.md Normal file
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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 />);

View 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;

1050
data.csv Normal file

File diff suppressed because it is too large Load Diff

57
debug-login.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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,
};

View 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
View 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
View 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
View 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
View 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,
'', '', '', '', // 710 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
View 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
View 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
View 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,
},
};