import React from 'react'; import superagent from 'superagent'; import { Modal, Button, Form, Message, Icon } from 'semantic-ui-react'; /** * Step-up verification flow for OAuth client mutations. * * Opened automatically when the host component catches a 428 * `step_up_required` response from a mutation. The flow: * * 1. User clicks "Send code". We POST to `/:id/step-up/challenge` * which emails a 6-digit code to their Bigscreen account. * 2. User enters the code in a text field. * 3. User clicks "Verify". We POST to `/:id/step-up/verify`. * On success, the modal closes and the host calls `onVerified()` * which should retry whatever mutation originally failed with 428. * * Props: * open bool whether the modal is visible * clientUniqueId string /admin/oauth/clients/:uniqueId — the scope of the unlock * clientName string for display * onClose () user dismissed without verifying * onVerified () verification succeeded — host should retry its mutation */ export default class StepUpModal extends React.Component { constructor(props) { super(props); this.state = this.initialState(); } initialState() { return { phase: 'request', // 'request' | 'enter_code' | 'verifying' | 'sent_error' challengeId: null, code: '', error: null, sending: false }; } componentDidUpdate(prevProps) { // Reset when the modal re-opens so a prior failure doesn't carry over. if (this.props.open && !prevProps.open) { this.setState(this.initialState()); } } async onSendCode() { const { clientUniqueId } = this.props; this.setState({ sending: true, error: null }); try { const res = await superagent .post(`/api/admin/oauth/clients/${clientUniqueId}/step-up/challenge`) .accept('json'); this.setState({ sending: false, phase: 'enter_code', challengeId: res.body.challengeId }); } catch (e) { const body = e.response?.body || {}; this.setState({ sending: false, error: body.error_description || body.error || e.message, phase: body.error === 'locked' ? 'request' : this.state.phase }); } } async onVerify() { const { clientUniqueId, onVerified } = this.props; const { challengeId, code } = this.state; if (!challengeId || !code) return; this.setState({ sending: true, error: null, phase: 'verifying' }); try { await superagent .post(`/api/admin/oauth/clients/${clientUniqueId}/step-up/verify`) .accept('json') .send({ challengeId, code: code.trim() }); this.setState({ sending: false }); if (onVerified) onVerified(); } catch (e) { const body = e.response?.body || {}; const reason = body.error || e.message; // `mismatch` keeps them in the same phase to retry; `locked` / // `expired_or_unknown` force a restart from Send code. const backToRequest = reason === 'locked' || reason === 'expired_or_unknown'; this.setState({ sending: false, error: reasonToMessage(reason), phase: backToRequest ? 'request' : 'enter_code', challengeId: backToRequest ? null : challengeId, code: '' }); } } renderRequestPhase() { return ( <>

To modify {this.props.clientName}, we need to verify it's you.

We'll email a 6-digit code to the address on your Bigscreen account. The code is valid for 10 minutes and unlocks mutations on this client only.

); } renderEnterCodePhase() { return ( <> A code was sent to your email. Enter it below within 10 minutes.
this.setState({ code: d.value.replace(/\D/g, '').slice(0, 6) })} autoFocus maxLength={6} /> ); } render() { const { open, onClose, clientName } = this.props; const { phase, error, sending, code } = this.state; return ( Verify it's you {error && {error}} {phase === 'request' && this.renderRequestPhase()} {(phase === 'enter_code' || phase === 'verifying') && this.renderEnterCodePhase()} {phase === 'request' && ( )} {(phase === 'enter_code' || phase === 'verifying') && ( )} ); } } function reasonToMessage(reason) { switch (reason) { case 'mismatch': return 'That code is incorrect. Try again.'; case 'locked': return 'Too many failed attempts. Try again in 15 minutes.'; case 'expired_or_unknown': return 'That code is expired or already used. Request a new one.'; case 'scope_mismatch': return 'This code is for a different client. Request a new one.'; default: return reason; } }