u
This commit is contained in:
118
src/App.jsx
118
src/App.jsx
@@ -1,8 +1,9 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Component, lazy, Suspense } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import CompanySetupDialog from './components/CompanySetupDialog';
|
||||
import AppShell from './components/AppShell';
|
||||
@@ -36,60 +37,69 @@ function PageFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { status, needsCompanySetup } = useAuth();
|
||||
class App extends Component {
|
||||
render() {
|
||||
const { status, needsCompanySetup } = this.props;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'authed') {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (needsCompanySetup) {
|
||||
return <CompanySetupDialog />;
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/artikel" replace />} />
|
||||
<Route path="lieferung" element={<LieferungPage />} />
|
||||
<Route path="infos" element={<InfosPage />} />
|
||||
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
|
||||
<Route index element={<ArtikelUebersichtPage />} />
|
||||
<Route path="kategorien" element={<ArtikelKategorienPage />} />
|
||||
<Route path="lager" element={<ArtikelLagerPage />} />
|
||||
<Route path=":sku" element={<ArtikelSkuPage />} />
|
||||
</Route>
|
||||
<Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}>
|
||||
<Route index element={<AuftragAktivePage />} />
|
||||
<Route path="archiv" element={<AuftragArchivPage />} />
|
||||
</Route>
|
||||
<Route path="kunde" element={<SectionLayout sectionPath="/kunde" />}>
|
||||
<Route index element={<KundeListePage />} />
|
||||
<Route path="gruppen" element={<KundeGruppenPage />} />
|
||||
<Route path="kontakte" element={<KundeKontaktePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/artikel" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'authed') {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (needsCompanySetup) {
|
||||
return <CompanySetupDialog />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/artikel" replace />} />
|
||||
<Route path="lieferung" element={<LieferungPage />} />
|
||||
<Route path="infos" element={<InfosPage />} />
|
||||
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
|
||||
<Route index element={<ArtikelUebersichtPage />} />
|
||||
<Route path="kategorien" element={<ArtikelKategorienPage />} />
|
||||
<Route path="lager" element={<ArtikelLagerPage />} />
|
||||
<Route path=":sku" element={<ArtikelSkuPage />} />
|
||||
</Route>
|
||||
<Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}>
|
||||
<Route index element={<AuftragAktivePage />} />
|
||||
<Route path="archiv" element={<AuftragArchivPage />} />
|
||||
</Route>
|
||||
<Route path="kunde" element={<SectionLayout sectionPath="/kunde" />}>
|
||||
<Route index element={<KundeListePage />} />
|
||||
<Route path="gruppen" element={<KundeGruppenPage />} />
|
||||
<Route path="kontakte" element={<KundeKontaktePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/artikel" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
status: selectAuthStatus(state),
|
||||
needsCompanySetup: selectNeedsCompanySetup(state),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(App);
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { auth, company } from '../api';
|
||||
import { AuthContext } from './context';
|
||||
|
||||
function applyUser(session, setUser, setStatus) {
|
||||
const nextUser = session?.user ?? null;
|
||||
setUser(nextUser);
|
||||
setStatus(nextUser ? 'authed' : 'anon');
|
||||
return nextUser;
|
||||
}
|
||||
|
||||
export default function AuthProvider({ children }) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [user, setUser] = useState(null);
|
||||
const [googleClientId, setGoogleClientId] = useState(null);
|
||||
const [status, setStatus] = useState('loading'); // loading | authed | anon
|
||||
const [companyReady, setCompanyReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const session = await auth.me();
|
||||
if (!active) return;
|
||||
const nextUser = applyUser(session, setUser, setStatus);
|
||||
setCompanyReady(nextUser?.selectedCompanyId != null);
|
||||
setGoogleClientId(session.googleClientId);
|
||||
if (session.forbidden) {
|
||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
setStatus('anon');
|
||||
setCompanyReady(false);
|
||||
enqueueSnackbar(`Session check failed: ${err.message}`, {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loginWithGoogle = useCallback(async (credential) => {
|
||||
const { session } = await auth.loginWithGoogle(credential);
|
||||
if (!session?.user) {
|
||||
setUser(null);
|
||||
setStatus('anon');
|
||||
setCompanyReady(false);
|
||||
enqueueSnackbar(
|
||||
`Login ok but session cookie not accepted (/api/auth/me still unauthorized)`,
|
||||
{ variant: 'error', autoHideDuration: 12000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nextUser = applyUser(session, setUser, setStatus);
|
||||
setCompanyReady(nextUser?.selectedCompanyId != null);
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
const selectCompany = useCallback(async (companyId) => {
|
||||
const data = await company.select(companyId);
|
||||
if (data.user) setUser(data.user);
|
||||
setCompanyReady(true);
|
||||
}, []);
|
||||
|
||||
const createCompany = useCallback(async ({ name, iso639, taxrate, reducedtaxrate }) => {
|
||||
const data = await company.create({ name, iso639, taxrate, reducedtaxrate });
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
setCompanyReady(true);
|
||||
return;
|
||||
}
|
||||
if (data.company?.id) {
|
||||
const selected = await company.select(data.company.id);
|
||||
if (selected.user) setUser(selected.user);
|
||||
setCompanyReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await auth.logout();
|
||||
setUser(null);
|
||||
setStatus('anon');
|
||||
setCompanyReady(false);
|
||||
window.history.replaceState(null, '', '/');
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
googleClientId,
|
||||
status,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
createCompany,
|
||||
needsCompanySetup: status === 'authed' && !companyReady,
|
||||
}),
|
||||
[
|
||||
user,
|
||||
googleClientId,
|
||||
status,
|
||||
companyReady,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
createCompany,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from './context';
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
30
src/components/AuthBootstrap.jsx
Normal file
30
src/components/AuthBootstrap.jsx
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
src/main.jsx
30
src/main.jsx
@@ -1,26 +1,30 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Provider } from 'react-redux';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import AuthBootstrap from './components/AuthBootstrap';
|
||||
import App from './App';
|
||||
import { store } from './store';
|
||||
import theme from './theme';
|
||||
import './index.css';
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
autoHideDuration={2000}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>,
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
autoHideDuration={2000}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<AuthBootstrap>
|
||||
<App />
|
||||
</AuthBootstrap>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
93
src/store/authSlice.js
Normal file
93
src/store/authSlice.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { auth, company } from '../api';
|
||||
|
||||
export const bootstrapSession = createAsyncThunk('auth/bootstrap', async () => auth.me());
|
||||
|
||||
export const loginWithGoogle = createAsyncThunk('auth/login', async (credential) => {
|
||||
const { session } = await auth.loginWithGoogle(credential);
|
||||
if (!session?.user) {
|
||||
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
export const selectCompany = createAsyncThunk('auth/selectCompany', async (companyId) => {
|
||||
const data = await company.select(companyId);
|
||||
return data.user ?? { selectedCompanyId: companyId };
|
||||
});
|
||||
|
||||
export const createCompany = createAsyncThunk('auth/createCompany', async (payload) => {
|
||||
const data = await company.create(payload);
|
||||
if (data.user) return data.user;
|
||||
if (data.company?.id) {
|
||||
const selected = await company.select(data.company.id);
|
||||
return selected.user ?? { selectedCompanyId: data.company.id };
|
||||
}
|
||||
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
||||
});
|
||||
|
||||
export const logout = createAsyncThunk('auth/logout', async () => {
|
||||
await auth.logout();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
googleClientId: null,
|
||||
status: 'loading',
|
||||
companyReady: false,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(bootstrapSession.fulfilled, (state, action) => {
|
||||
const session = action.payload;
|
||||
state.user = session.user ?? null;
|
||||
state.googleClientId = session.googleClientId ?? null;
|
||||
state.status = session.user ? 'authed' : 'anon';
|
||||
state.companyReady = session.user?.selectedCompanyId != null;
|
||||
})
|
||||
.addCase(bootstrapSession.rejected, (state) => {
|
||||
state.user = null;
|
||||
state.status = 'anon';
|
||||
state.companyReady = false;
|
||||
})
|
||||
.addCase(loginWithGoogle.fulfilled, (state, action) => {
|
||||
const session = action.payload;
|
||||
state.user = session.user;
|
||||
state.googleClientId = session.googleClientId ?? state.googleClientId;
|
||||
state.status = 'authed';
|
||||
state.companyReady = session.user?.selectedCompanyId != null;
|
||||
})
|
||||
.addCase(loginWithGoogle.rejected, (state) => {
|
||||
state.user = null;
|
||||
state.status = 'anon';
|
||||
state.companyReady = false;
|
||||
})
|
||||
.addCase(selectCompany.fulfilled, (state, action) => {
|
||||
state.user = { ...state.user, ...action.payload };
|
||||
state.companyReady = true;
|
||||
})
|
||||
.addCase(createCompany.fulfilled, (state, action) => {
|
||||
state.user = { ...state.user, ...action.payload };
|
||||
state.companyReady = true;
|
||||
})
|
||||
.addCase(logout.fulfilled, (state) => {
|
||||
state.user = null;
|
||||
state.status = 'anon';
|
||||
state.companyReady = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const selectAuthStatus = (state) => state.auth.status;
|
||||
export const selectAuthUser = (state) => state.auth.user;
|
||||
export const selectGoogleClientId = (state) => state.auth.googleClientId;
|
||||
export const selectNeedsCompanySetup = (state) =>
|
||||
state.auth.status === 'authed' && !state.auth.companyReady;
|
||||
|
||||
export default authSlice.reducer;
|
||||
8
src/store/index.js
Normal file
8
src/store/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from './authSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user