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

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;