u
This commit is contained in:
437
src/routes/oauth/OAuthAuthorizePage.jsx
Normal file
437
src/routes/oauth/OAuthAuthorizePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user