This commit is contained in:
seb
2026-10-13 00:33:38 +02:00
parent 1ad9b14aa6
commit 4c5ccf7f64
12 changed files with 1152 additions and 645 deletions

View File

@@ -0,0 +1,437 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Chip from '@mui/material/Chip';
import Alert from '@mui/material/Alert';
import Divider from '@mui/material/Divider';
import Avatar from '@mui/material/Avatar';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ComputerIcon from '@mui/icons-material/Computer';
import SecurityIcon from '@mui/icons-material/Security';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import ConstructionIcon from '@mui/icons-material/Construction';
import { mcp } from '../../api';
import authAutomat from '../../automats/authAutomat';
import { notifyError } from '../../utils/notify';
import quixposLogo from '../../assets/quixpos-logo.png';
import {
MAIN_BACKGROUND,
SURFACE_BASE,
SURFACE_RAISED,
BORDER,
BORDER_STRONG,
DARK_2,
DARK_4,
} from '../../colors';
export default class OAuthAuthorizePage extends Component {
constructor(props) {
super(props);
this.state = {
authStatus: authAutomat.state.status,
loading: true,
submitting: null, // 'allow' | 'deny' | null
consent: null,
error: null,
};
this.fetchConsent = this.fetchConsent.bind(this);
this.handleAllow = this.handleAllow.bind(this);
this.handleDeny = this.handleDeny.bind(this);
this.redirectToLogin = this.redirectToLogin.bind(this);
}
componentDidMount() {
document.title = 'Zugriffsanfrage autorisieren * Dash MCP';
this.unsubAuth = authAutomat.subscribe(this, (state) => {
const prevStatus = this.state.authStatus;
const nextStatus = state.status;
if (prevStatus !== nextStatus) {
this.setState({ authStatus: nextStatus });
if (nextStatus === 'authed') {
void this.fetchConsent();
} else if (nextStatus === 'anon') {
this.redirectToLogin();
}
}
});
if (this.state.authStatus === 'authed') {
void this.fetchConsent();
} else if (this.state.authStatus === 'anon') {
this.redirectToLogin();
}
}
componentWillUnmount() {
this.unsubAuth?.();
}
redirectToLogin() {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`/login?returnTo=${returnTo}`);
}
// #region fetchConsent
// MARK: calls GET /api/mcp/authorize from ./api
async fetchConsent() {
this.setState({ loading: true, error: null });
try {
const search = window.location.search;
const data = await mcp.getAuthorize(search);
this.setState({ consent: data, loading: false });
} catch (err) {
this.setState({
loading: false,
error: err?.message || 'Fehler beim Laden der Autorisierungsdetails',
});
notifyError(err);
}
}
// #endregion
// #region handleAllow
// MARK: calls POST /api/mcp/authorize (action: allow)
async handleAllow() {
const { consent, submitting } = this.state;
if (!consent || submitting) return;
const params = consent.params || {};
this.setState({ submitting: 'allow', error: null });
try {
const payload = {
client_id: params.clientId || consent.client?.clientId,
redirect_uri: params.redirectUri,
code_challenge: params.codeChallenge,
code_challenge_method: params.codeChallengeMethod,
state: params.state,
scope: params.scope || consent.client?.scope || 'mcp:tools',
action: 'allow',
};
const res = await mcp.postAuthorize(payload);
const redirectUrl = res?.redirectUrl || res?.data?.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
throw new Error('Keine Weiterleitungs-URL vom Server erhalten.');
}
} catch (err) {
this.setState({ submitting: null });
notifyError(err);
}
}
// #endregion
// #region handleDeny
// MARK: calls POST /api/mcp/authorize (action: deny)
async handleDeny() {
const { consent, submitting } = this.state;
if (!consent || submitting) return;
const params = consent.params || {};
this.setState({ submitting: 'deny', error: null });
try {
const payload = {
client_id: params.clientId || consent.client?.clientId,
redirect_uri: params.redirectUri,
state: params.state,
action: 'deny',
};
const res = await mcp.postAuthorize(payload);
const redirectUrl = res?.redirectUrl || res?.data?.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
throw new Error('Keine Weiterleitungs-URL vom Server erhalten.');
}
} catch (err) {
this.setState({ submitting: null });
notifyError(err);
}
}
// #endregion
render() {
const { authStatus, loading, submitting, consent, error } = this.state;
if (authStatus === 'loading' || (authStatus === 'authed' && loading)) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: MAIN_BACKGROUND,
gap: 2,
}}
>
<CircularProgress size={44} />
<Typography variant="body2" sx={{ color: DARK_2 }}>
Autorisierungsanfrage wird geladen...
</Typography>
</Box>
);
}
const client = consent?.client || {};
const user = consent?.user || authAutomat.state.user || {};
const params = consent?.params || {};
const clientName = client.clientName || 'MCP-Anwendung';
const scope = params.scope || client.scope || 'mcp:tools';
return (
<Box
sx={{
minHeight: '100vh',
bgcolor: MAIN_BACKGROUND,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
}}
>
<Card
sx={{
maxWidth: 520,
width: '100%',
borderRadius: 3,
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.12)',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{/* Top Brand Header */}
<Box
sx={{
py: 2.5,
px: 3,
bgcolor: SURFACE_BASE,
borderBottom: '1px solid',
borderColor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
component="img"
src={quixposLogo}
alt="QuixPOS"
sx={{ height: 28, objectFit: 'contain' }}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 700, letterSpacing: '0.04em' }}>
DASH MCP
</Typography>
</Stack>
<Chip
icon={<SecurityIcon sx={{ '&&': { fontSize: 16 } }} />}
label="OAuth 2.1"
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
borderColor: BORDER_STRONG,
color: 'text.secondary',
}}
/>
</Box>
<CardContent sx={{ p: 3.5 }}>
{error ? (
<Stack spacing={2.5}>
<Alert severity="error">
{error}
</Alert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Die Autorisierungsanfrage ist ungültig oder abgelaufen. Bitte starten Sie die Anmeldung in Ihrem MCP-Client erneut.
</Typography>
<Button
variant="outlined"
onClick={() => window.location.reload()}
sx={{ alignSelf: 'flex-start' }}
>
Erneut versuchen
</Button>
</Stack>
) : (
<Stack spacing={3}>
{/* Visual handshake illustration */}
<Stack direction="row" alignItems="center" justifyContent="center" spacing={2.5} sx={{ py: 1 }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '14px',
bgcolor: SURFACE_RAISED,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'primary.main',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<ComputerIcon sx={{ fontSize: 30 }} />
</Box>
<SyncAltIcon sx={{ color: DARK_2, fontSize: 24 }} />
<Box
sx={{
width: 56,
height: 56,
borderRadius: '14px',
bgcolor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
boxShadow: '0 4px 12px rgba(15, 118, 110, 0.3)',
}}
>
<ConstructionIcon sx={{ fontSize: 28 }} />
</Box>
</Stack>
{/* Main Heading */}
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5, color: DARK_4 }}>
{clientName} autorisieren?
</Typography>
<Typography variant="body2" sx={{ color: DARK_2 }}>
Diese Anwendung möchte Zugriff auf Ihre QuixPOS / Dashback Tools erhalten.
</Typography>
</Box>
<Divider />
{/* Account row */}
<Box
sx={{
p: 1.75,
borderRadius: 2,
bgcolor: SURFACE_BASE,
border: '1px solid',
borderColor: BORDER,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar
sx={{
width: 34,
height: 34,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600,
}}
>
{(user.email || 'U')[0].toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', lineHeight: 1.2 }}>
Autorisieren als Benutzer
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, noWrap: true }}>
{user.email || 'Unbekanntes Konto'}
</Typography>
</Box>
</Stack>
</Box>
{/* Scope & Permissions row */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: '0.05em', display: 'block', mb: 1 }}>
Angefordertes Zugriffsrecht
</Typography>
<Box
sx={{
p: 2,
borderRadius: 2,
bgcolor: SURFACE_BASE,
border: '1px solid',
borderColor: BORDER,
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Chip
label={scope}
size="small"
sx={{
fontWeight: 600,
fontFamily: 'monospace',
bgcolor: 'primary.light',
color: 'primary.dark',
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Dashback Tools API
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: DARK_2, fontSize: '0.85rem' }}>
Ermöglicht dem MCP-Client das Ausführen von Werkzeugen und Abfragen (Artikel, Lager, Kunden, Aufträge) in Ihrem Namen.
</Typography>
</Box>
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', display: 'block' }}>
Sie können diese Autorisierung jederzeit in den Einstellungen unter <strong>Sicherheit & MCP</strong> widerrufen.
</Typography>
{/* Action buttons */}
<Stack direction="row" spacing={2} sx={{ pt: 1 }}>
<Button
variant="outlined"
color="inherit"
fullWidth
size="large"
disabled={submitting != null}
startIcon={<CloseIcon />}
onClick={this.handleDeny}
sx={{
borderRadius: 2,
textTransform: 'none',
borderColor: BORDER_STRONG,
}}
>
{submitting === 'deny' ? 'Wird abgelehnt...' : 'Ablehnen'}
</Button>
<Button
variant="contained"
color="primary"
fullWidth
size="large"
disabled={submitting != null}
startIcon={submitting === 'allow' ? <CircularProgress size={18} color="inherit" /> : <CheckIcon />}
onClick={this.handleAllow}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
}}
>
{submitting === 'allow' ? 'Autorisiere...' : 'Zulassen'}
</Button>
</Stack>
</Stack>
)}
</CardContent>
</Card>
</Box>
);
}
}