This commit is contained in:
seb
2026-07-31 07:02:51 +02:00
parent 9dac151854
commit a95b7f4b1e
14 changed files with 1064 additions and 831 deletions

View File

@@ -0,0 +1,30 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack';
import { bootstrapSession } from '../store/authSlice';
class AuthBootstrap extends Component {
componentDidMount() {
this.props
.bootstrapSession()
.unwrap()
.then((session) => {
if (session.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' });
}
})
.catch((err) => {
enqueueSnackbar(`Session check failed: ${err.message}`, { variant: 'error' });
});
}
render() {
return this.props.children;
}
}
const mapDispatchToProps = (dispatch) => ({
bootstrapSession: () => dispatch(bootstrapSession()),
});
export default connect(null, mapDispatchToProps)(AuthBootstrap);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Component } from 'react';
import { connect } from 'react-redux';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -14,72 +15,75 @@ import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { useSnackbar } from 'notistack';
import { enqueueSnackbar } from 'notistack';
import { company } from '../api';
import { useAuth } from '../auth/useAuth';
import { selectAuthUser } from '../store/authSlice';
import {
ISO639_OPTIONS,
companyLanguageToIso639,
parseTaxRate,
} from '../utils/companyForm';
export default function CompanySettingsDialog({ open, onClose }) {
const { user } = useAuth();
const { enqueueSnackbar } = useSnackbar();
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [name, setName] = useState('');
const [iso639, setIso639] = useState('de');
const [taxrate, setTaxrate] = useState('');
const [reducedtaxrate, setReducedtaxrate] = useState('');
const companyId = user?.selectedCompanyId;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
useEffect(() => {
if (!open || !companyId) return;
let active = true;
(async () => {
setLoading(true);
setError(null);
try {
const data = await company.getById(companyId);
if (!active) return;
const item = data.company;
setName(item.name ?? '');
setIso639(companyLanguageToIso639(item));
setTaxrate(item.taxrate != null ? String(item.taxrate) : '');
setReducedtaxrate(item.reducedtaxrate != null ? String(item.reducedtaxrate) : '');
} catch (err) {
if (!active) return;
setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
class CompanySettingsDialog extends Component {
constructor(props) {
super(props);
this.state = {
loading: false,
busy: false,
error: null,
name: '',
iso639: 'de',
taxrate: '',
reducedtaxrate: '',
};
}, [open, companyId]);
this.handleSubmit = this.handleSubmit.bind(this);
}
const handleSubmit = async (event) => {
componentDidUpdate(prevProps) {
const { open, user } = this.props;
const companyId = user?.selectedCompanyId;
if (open && !prevProps.open && companyId) {
void this.loadCompany(companyId);
}
}
async loadCompany(companyId) {
this.setState({ loading: true, error: null });
try {
const data = await company.getById(companyId);
const item = data.company;
this.setState({
name: item.name ?? '',
iso639: companyLanguageToIso639(item),
taxrate: item.taxrate != null ? String(item.taxrate) : '',
reducedtaxrate: item.reducedtaxrate != null ? String(item.reducedtaxrate) : '',
});
} catch (err) {
this.setState({ error: err.message });
} finally {
this.setState({ loading: false });
}
}
async handleSubmit(event) {
event.preventDefault();
const { user, onClose } = this.props;
const { name, iso639, taxrate, reducedtaxrate } = this.state;
const companyId = user?.selectedCompanyId;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
if (!companyId || !canSave) {
setError('Bitte alle Pflichtfelder ausfüllen.');
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
return;
}
setBusy(true);
setError(null);
this.setState({ busy: true, error: null });
try {
await company.update(companyId, {
name: name.trim(),
@@ -90,106 +94,124 @@ export default function CompanySettingsDialog({ open, onClose }) {
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
onClose();
} catch (err) {
setError(err.message);
this.setState({ error: err.message });
} finally {
setBusy(false);
this.setState({ busy: false });
}
};
}
return (
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle>Unternehmen bearbeiten</DialogTitle>
<Box component="form" onSubmit={handleSubmit} noValidate>
<DialogContent>
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
) : null}
render() {
const { open, onClose } = this.props;
const { loading, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={2}>
<TextField
fullWidth
required
size="small"
label="Name"
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
autoComplete="organization"
autoFocus
/>
return (
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle>Unternehmen bearbeiten</DialogTitle>
<Box component="form" onSubmit={this.handleSubmit} noValidate>
<DialogContent>
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
) : null}
<FormControl required>
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
<ToggleButtonGroup
exclusive
required
size="small"
value={iso639}
disabled={busy}
onChange={(_event, value) => {
if (value) setIso639(value);
}}
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={1}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={2}>
<TextField
fullWidth
required
size="small"
type="number"
label="Normalsatz"
value={taxrate}
label="Name"
value={name}
disabled={busy}
onChange={(e) => setTaxrate(e.target.value)}
error={taxrate !== '' && parsedTaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
<TextField
fullWidth
required
size="small"
type="number"
label="Ermäßigter Satz"
value={reducedtaxrate}
disabled={busy}
onChange={(e) => setReducedtaxrate(e.target.value)}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
onChange={(e) => this.setState({ name: e.target.value })}
autoComplete="organization"
autoFocus
/>
<FormControl required>
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
<ToggleButtonGroup
exclusive
required
size="small"
value={iso639}
disabled={busy}
onChange={(_event, value) => {
if (value) this.setState({ iso639: value });
}}
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
required
size="small"
type="number"
label="Normalsatz"
value={taxrate}
disabled={busy}
onChange={(e) => this.setState({ taxrate: e.target.value })}
error={taxrate !== '' && parsedTaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
<TextField
fullWidth
required
size="small"
type="number"
label="Ermäßigter Satz"
value={reducedtaxrate}
disabled={busy}
onChange={(e) => this.setState({ reducedtaxrate: e.target.value })}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
</Stack>
</Stack>
</Stack>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={busy}>
Abbrechen
</Button>
<Button type="submit" variant="contained" disabled={busy || loading || !canSave}>
Speichern
</Button>
</DialogActions>
</Box>
</Dialog>
);
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={busy}>
Abbrechen
</Button>
<Button type="submit" variant="contained" disabled={busy || loading || !canSave}>
Speichern
</Button>
</DialogActions>
</Box>
</Dialog>
);
}
}
const mapStateToProps = (state) => ({
user: selectAuthUser(state),
});
export default connect(mapStateToProps)(CompanySettingsDialog);

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Component } from 'react';
import { connect } from 'react-redux';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -20,225 +21,256 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
import BusinessIcon from '@mui/icons-material/Business';
import { company } from '../api';
import { useAuth } from '../auth/useAuth';
import { createCompany, logout, selectCompany } from '../store/authSlice';
import {
defaultIso639,
ISO639_OPTIONS,
parseTaxRate,
} from '../utils/companyForm';
export default function CompanySetupDialog() {
const { createCompany, selectCompany, logout } = useAuth();
const [companies, setCompanies] = useState([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [name, setName] = useState('');
const [iso639, setIso639] = useState(defaultIso639);
const [taxrate, setTaxrate] = useState('19');
const [reducedtaxrate, setReducedtaxrate] = useState('7');
class CompanySetupDialog extends Component {
constructor(props) {
super(props);
this.state = {
companies: [],
loading: true,
busy: false,
error: null,
name: '',
iso639: defaultIso639(),
taxrate: '19',
reducedtaxrate: '7',
};
this.handleSelect = this.handleSelect.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleLogout = this.handleLogout.bind(this);
}
const hasCompanies = companies.length > 0;
componentDidMount() {
void this.loadCompanies();
}
const loadCompanies = useCallback(async () => {
setLoading(true);
async loadCompanies() {
this.setState({ loading: true });
try {
const data = await company.list();
setCompanies(data.companies ?? []);
this.setState({ companies: data.companies ?? [] });
} catch {
setCompanies([]);
this.setState({ companies: [] });
} finally {
setLoading(false);
this.setState({ loading: false });
}
}, []);
}
useEffect(() => {
void loadCompanies();
}, [loadCompanies]);
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canCreate =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
const handleSelect = async (companyId) => {
setBusy(true);
setError(null);
async handleSelect(companyId) {
this.setState({ busy: true, error: null });
try {
await selectCompany(companyId);
await this.props.selectCompany(companyId).unwrap();
} catch (err) {
setError(err.message);
this.setState({ error: err.message });
} finally {
setBusy(false);
this.setState({ busy: false });
}
};
}
const handleCreate = async (event) => {
async handleCreate(event) {
event.preventDefault();
const { name, iso639, taxrate, reducedtaxrate } = this.state;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canCreate =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
if (!canCreate) {
setError('Bitte alle Pflichtfelder ausfüllen.');
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
return;
}
setBusy(true);
setError(null);
this.setState({ busy: true, error: null });
try {
await createCompany({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
});
await this.props
.createCompany({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
})
.unwrap();
} catch (err) {
setError(err.message);
this.setState({ error: err.message });
} finally {
setBusy(false);
this.setState({ busy: false });
}
};
}
return (
<Dialog open disableEscapeKeyDown maxWidth="sm" fullWidth>
<DialogTitle>
{loading
? 'Unternehmen'
: hasCompanies
? 'Unternehmen auswählen'
: 'Unternehmen anlegen'}
</DialogTitle>
<DialogContent>
{!loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{hasCompanies
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
: 'Legen Sie Ihr Unternehmen an, um fortzufahren.'}
</Typography>
) : null}
handleLogout() {
void this.props.logout();
}
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
) : null}
render() {
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } =
this.state;
const hasCompanies = companies.length > 0;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canCreate =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={3}>
{hasCompanies ? (
<Box>
<List disablePadding sx={{ bgcolor: 'background.paper', borderRadius: 1 }}>
{companies.map((item) => (
<ListItemButton
key={item.id}
disabled={busy}
onClick={() => handleSelect(item.id)}
>
<BusinessIcon sx={{ mr: 1.5, color: 'primary.main' }} />
<ListItemText primary={item.name} />
</ListItemButton>
))}
</List>
</Box>
) : null}
return (
<Dialog open disableEscapeKeyDown maxWidth="sm" fullWidth>
<DialogTitle>
{loading
? 'Unternehmen'
: hasCompanies
? 'Unternehmen auswählen'
: 'Unternehmen anlegen'}
</DialogTitle>
<DialogContent>
{!loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{hasCompanies
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
: 'Legen Sie Ihr Unternehmen an, um fortzufahren.'}
</Typography>
) : null}
{hasCompanies ? <Divider>oder</Divider> : null}
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
) : null}
<Box component="form" onSubmit={handleCreate} noValidate>
<Stack spacing={2}>
{hasCompanies ? (
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
) : null}
<TextField
fullWidth
required
size="small"
label="Name"
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
autoComplete="organization"
autoFocus={!hasCompanies}
/>
<FormControl required>
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
<ToggleButtonGroup
exclusive
required
size="small"
value={iso639}
disabled={busy}
onChange={(_event, value) => {
if (value) setIso639(value);
}}
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
required
size="small"
type="number"
label="Normalsatz"
value={taxrate}
disabled={busy}
onChange={(e) => setTaxrate(e.target.value)}
error={taxrate !== '' && parsedTaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
<TextField
fullWidth
required
size="small"
type="number"
label="Ermäßigter Satz"
value={reducedtaxrate}
disabled={busy}
onChange={(e) => setReducedtaxrate(e.target.value)}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
</Stack>
<Button type="submit" variant="contained" disabled={busy || !canCreate}>
Anlegen
</Button>
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={3}>
{hasCompanies ? (
<Box>
<List disablePadding sx={{ bgcolor: 'background.paper', borderRadius: 1 }}>
{companies.map((item) => (
<ListItemButton
key={item.id}
disabled={busy}
onClick={() => this.handleSelect(item.id)}
>
<BusinessIcon sx={{ mr: 1.5, color: 'primary.main' }} />
<ListItemText primary={item.name} />
</ListItemButton>
))}
</List>
</Box>
) : null}
<Button
type="button"
variant="text"
color="inherit"
disabled={busy}
onClick={() => logout()}
sx={{ alignSelf: 'center' }}
>
Abmelden
</Button>
</Stack>
)}
</DialogContent>
</Dialog>
);
{hasCompanies ? <Divider>oder</Divider> : null}
<Box component="form" onSubmit={this.handleCreate} noValidate>
<Stack spacing={2}>
{hasCompanies ? (
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
) : null}
<TextField
fullWidth
required
size="small"
label="Name"
value={name}
disabled={busy}
onChange={(e) => this.setState({ name: e.target.value })}
autoComplete="organization"
autoFocus={!hasCompanies}
/>
<FormControl required>
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
<ToggleButtonGroup
exclusive
required
size="small"
value={iso639}
disabled={busy}
onChange={(_event, value) => {
if (value) this.setState({ iso639: value });
}}
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
required
size="small"
type="number"
label="Normalsatz"
value={taxrate}
disabled={busy}
onChange={(e) => this.setState({ taxrate: e.target.value })}
error={taxrate !== '' && parsedTaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
<TextField
fullWidth
required
size="small"
type="number"
label="Ermäßigter Satz"
value={reducedtaxrate}
disabled={busy}
onChange={(e) => this.setState({ reducedtaxrate: e.target.value })}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
</Stack>
<Button type="submit" variant="contained" disabled={busy || !canCreate}>
Anlegen
</Button>
</Stack>
</Box>
<Button
type="button"
variant="text"
color="inherit"
disabled={busy}
onClick={this.handleLogout}
sx={{ alignSelf: 'center' }}
>
Abmelden
</Button>
</Stack>
)}
</DialogContent>
</Dialog>
);
}
}
const mapDispatchToProps = (dispatch) => ({
createCompany: (payload) => dispatch(createCompany(payload)),
selectCompany: (companyId) => dispatch(selectCompany(companyId)),
logout: () => dispatch(logout()),
});
export default connect(null, mapDispatchToProps)(CompanySetupDialog);

View File

@@ -1,13 +1,10 @@
import { useState } from 'react';
import { Component } from 'react';
import { connect } from 'react-redux';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import GoogleIcon from '@mui/icons-material/Google';
import { useSnackbar } from 'notistack';
import { useAuth } from '../auth/useAuth';
import { enqueueSnackbar } from 'notistack';
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
import {
abortGoogleSignIn,
FEDCM_BLOCKED_MESSAGE,
@@ -17,12 +14,15 @@ import {
requestGoogleIdToken,
} from '../auth/googleSignIn';
export default function LoginPage() {
const { loginWithGoogle, googleClientId, status } = useAuth();
const { enqueueSnackbar } = useSnackbar();
const [busy, setBusy] = useState(false);
class LoginPage extends Component {
constructor(props) {
super(props);
this.state = { busy: false };
this.handleLogin = this.handleLogin.bind(this);
}
const handleLogin = async () => {
async handleLogin() {
const { googleClientId } = this.props;
abortGoogleSignIn();
try {
const idToken = await requestGoogleIdToken(googleClientId, {
@@ -33,8 +33,8 @@ export default function LoginPage() {
});
},
});
setBusy(true);
await loginWithGoogle(idToken);
this.setState({ busy: true });
await this.props.loginWithGoogle(idToken).unwrap();
} catch (err) {
if (err instanceof GoogleSignInCancelledError) return;
logError('[login] Google sign-in failed', {
@@ -64,32 +64,47 @@ export default function LoginPage() {
.join(' · ');
enqueueSnackbar(`Login failed: ${detail}`, { variant: 'error' });
} finally {
setBusy(false);
this.setState({ busy: false });
}
};
}
const loading = status === 'loading';
const ready = Boolean(googleClientId);
render() {
const { busy } = this.state;
const { status, googleClientId } = this.props;
const loading = status === 'loading';
const ready = Boolean(googleClientId);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
}}
>
<Button
variant="contained"
size="large"
startIcon={<GoogleIcon />}
disabled={busy || loading || !ready}
onClick={handleLogin}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
}}
>
{busy ? 'Signing in…' : 'Sign in with Google'}
</Button>
</Box>
);
<Button
variant="contained"
size="large"
startIcon={<GoogleIcon />}
disabled={busy || loading || !ready}
onClick={this.handleLogin}
>
{busy ? 'Signing in…' : 'Sign in with Google'}
</Button>
</Box>
);
}
}
const mapStateToProps = (state) => ({
status: selectAuthStatus(state),
googleClientId: selectGoogleClientId(state),
});
const mapDispatchToProps = (dispatch) => ({
loginWithGoogle: (credential) => dispatch(loginWithGoogle(credential)),
});
export default connect(mapStateToProps, mapDispatchToProps)(LoginPage);

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Component } from 'react';
import { connect } from 'react-redux';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
@@ -8,62 +9,95 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business';
import { useAuth } from '../auth/useAuth';
import { logout, selectAuthUser } from '../store/authSlice';
import CompanySettingsDialog from './CompanySettingsDialog';
// Menu open/close state is local; toggling it only re-renders this component.
export default function UserMenu() {
const { user, logout } = useAuth();
const [anchorEl, setAnchorEl] = useState(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const open = Boolean(anchorEl);
class UserMenu extends Component {
constructor(props) {
super(props);
this.state = {
anchorEl: null,
settingsOpen: false,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
this.handleOpenSettings = this.handleOpenSettings.bind(this);
this.handleCloseSettings = this.handleCloseSettings.bind(this);
this.handleLogout = this.handleLogout.bind(this);
}
return (
<>
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
<IconButton
onClick={(e) => setAnchorEl(e.currentTarget)}
onMouseDown={(e) => e.preventDefault()}
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
handleOpenMenu(event) {
this.setState({ anchorEl: event.currentTarget });
}
handleCloseMenu() {
this.setState({ anchorEl: null });
}
handleOpenSettings() {
this.setState({ anchorEl: null, settingsOpen: true });
}
handleCloseSettings() {
this.setState({ settingsOpen: false });
}
handleLogout() {
this.setState({ anchorEl: null });
void this.props.logout();
}
render() {
const { user } = this.props;
const { anchorEl, settingsOpen } = this.state;
const open = Boolean(anchorEl);
return (
<>
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
<IconButton
onClick={this.handleOpenMenu}
onMouseDown={(e) => e.preventDefault()}
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
>
<Avatar src={user?.picture} alt={user?.email} sx={{ width: 32, height: 32 }}>
{user?.email?.[0]?.toUpperCase()}
</Avatar>
</IconButton>
</Box>
<Menu
anchorEl={anchorEl}
open={open}
onClose={this.handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Avatar src={user?.picture} alt={user?.email} sx={{ width: 32, height: 32 }}>
{user?.email?.[0]?.toUpperCase()}
</Avatar>
</IconButton>
</Box>
<Menu
anchorEl={anchorEl}
open={open}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<MenuItem disabled>{user?.email}</MenuItem>
<MenuItem
disabled={!user?.selectedCompanyId}
onClick={() => {
setAnchorEl(null);
setSettingsOpen(true);
}}
>
<ListItemIcon>
<BusinessIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Unternehmen</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
setAnchorEl(null);
logout();
}}
>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Sign out</ListItemText>
</MenuItem>
</Menu>
<CompanySettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
<MenuItem disabled>{user?.email}</MenuItem>
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenSettings}>
<ListItemIcon>
<BusinessIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Unternehmen</ListItemText>
</MenuItem>
<MenuItem onClick={this.handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Sign out</ListItemText>
</MenuItem>
</Menu>
<CompanySettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
</>
);
}
}
const mapStateToProps = (state) => ({
user: selectAuthUser(state),
});
const mapDispatchToProps = (dispatch) => ({
logout: () => dispatch(logout()),
});
export default connect(mapStateToProps, mapDispatchToProps)(UserMenu);