mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
import {Fragment} from 'react';
|
|
import type {WithRouterProps} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import {QRCodeCanvas} from 'qrcode.react';
|
|
|
|
import {
|
|
addErrorMessage,
|
|
addLoadingMessage,
|
|
addSuccessMessage,
|
|
} from 'sentry/actionCreators/indicator';
|
|
import {openRecoveryOptions} from 'sentry/actionCreators/modal';
|
|
import {fetchOrganizationByMember} from 'sentry/actionCreators/organizations';
|
|
import {Alert} from 'sentry/components/alert';
|
|
import {Button} from 'sentry/components/button';
|
|
import ButtonBar from 'sentry/components/buttonBar';
|
|
import CircleIndicator from 'sentry/components/circleIndicator';
|
|
import FieldGroup from 'sentry/components/forms/fieldGroup';
|
|
import type {FormProps} from 'sentry/components/forms/form';
|
|
import Form from 'sentry/components/forms/form';
|
|
import JsonForm from 'sentry/components/forms/jsonForm';
|
|
import FormModel from 'sentry/components/forms/model';
|
|
import type {FieldObject} from 'sentry/components/forms/types';
|
|
import PanelItem from 'sentry/components/panels/panelItem';
|
|
import TextCopyInput from 'sentry/components/textCopyInput';
|
|
import U2fsign from 'sentry/components/u2f/u2fsign';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Authenticator} from 'sentry/types';
|
|
import getPendingInvite from 'sentry/utils/getPendingInvite';
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import withSentryRouter from 'sentry/utils/withSentryRouter';
|
|
import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
|
|
import RemoveConfirm from 'sentry/views/settings/account/accountSecurity/components/removeConfirm';
|
|
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
|
|
import TextBlock from 'sentry/views/settings/components/text/textBlock';
|
|
|
|
type GetFieldsOpts = {
|
|
authenticator: Authenticator;
|
|
/**
|
|
* Flag to track if totp has been sent
|
|
*/
|
|
hasSentCode: boolean;
|
|
/**
|
|
* Callback to reset SMS 2fa enrollment
|
|
*/
|
|
onSmsReset: () => void;
|
|
/**
|
|
* Callback when u2f device is activated
|
|
*/
|
|
onU2fTap: React.ComponentProps<typeof U2fsign>['onTap'];
|
|
/**
|
|
* Flag to track if we are currently sending the otp code
|
|
*/
|
|
sendingCode: boolean;
|
|
};
|
|
|
|
/**
|
|
* Retrieve additional form fields (or modify ones) based on 2fa method
|
|
*/
|
|
const getFields = ({
|
|
authenticator,
|
|
hasSentCode,
|
|
sendingCode,
|
|
onSmsReset,
|
|
onU2fTap,
|
|
}: GetFieldsOpts): null | FieldObject[] => {
|
|
const {form} = authenticator;
|
|
|
|
if (!form) {
|
|
return null;
|
|
}
|
|
|
|
if (authenticator.id === 'totp') {
|
|
return [
|
|
() => (
|
|
<CodeContainer key="qrcode">
|
|
<StyledQRCode
|
|
aria-label={t('Enrollment QR Code')}
|
|
value={authenticator.qrcode}
|
|
size={228}
|
|
/>
|
|
</CodeContainer>
|
|
),
|
|
() => (
|
|
<FieldGroup key="secret" label={t('Authenticator secret')}>
|
|
<TextCopyInput>{authenticator.secret ?? ''}</TextCopyInput>
|
|
</FieldGroup>
|
|
),
|
|
...form,
|
|
() => (
|
|
<Actions key="confirm">
|
|
<Button priority="primary" type="submit">
|
|
{t('Confirm')}
|
|
</Button>
|
|
</Actions>
|
|
),
|
|
];
|
|
}
|
|
|
|
// Sms Form needs a start over button + confirm button
|
|
// Also inputs being disabled vary based on hasSentCode
|
|
if (authenticator.id === 'sms') {
|
|
// Ideally we would have greater flexibility when rendering footer
|
|
return [
|
|
{...form[0], disabled: sendingCode || hasSentCode},
|
|
...(hasSentCode ? [{...form[1], required: true}] : []),
|
|
() => (
|
|
<Actions key="sms-footer">
|
|
<ButtonBar gap={1}>
|
|
{hasSentCode && <Button onClick={onSmsReset}>{t('Start Over')}</Button>}
|
|
<Button priority="primary" type="submit">
|
|
{hasSentCode ? t('Confirm') : t('Send Code')}
|
|
</Button>
|
|
</ButtonBar>
|
|
</Actions>
|
|
),
|
|
];
|
|
}
|
|
|
|
// Need to render device name field + U2f component
|
|
if (authenticator.id === 'u2f') {
|
|
const deviceNameField = form.find(({name}) => name === 'deviceName')!;
|
|
return [
|
|
deviceNameField,
|
|
() => (
|
|
<U2fsign
|
|
key="u2f-enroll"
|
|
style={{marginBottom: 0}}
|
|
challengeData={authenticator.challenge}
|
|
displayMode="enroll"
|
|
onTap={onU2fTap}
|
|
/>
|
|
),
|
|
];
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
type Props = DeprecatedAsyncView['props'] & WithRouterProps<{authId: string}, {}> & {};
|
|
|
|
type State = DeprecatedAsyncView['state'] & {
|
|
authenticator: Authenticator | null;
|
|
hasSentCode: boolean;
|
|
sendingCode: boolean;
|
|
};
|
|
|
|
type PendingInvite = ReturnType<typeof getPendingInvite>;
|
|
|
|
/**
|
|
* Renders necessary forms in order to enroll user in 2fa
|
|
*/
|
|
class AccountSecurityEnroll extends DeprecatedAsyncView<Props, State> {
|
|
formModel = new FormModel();
|
|
|
|
getTitle() {
|
|
return t('Security');
|
|
}
|
|
|
|
getDefaultState() {
|
|
return {...super.getDefaultState(), hasSentCode: false};
|
|
}
|
|
|
|
get authenticatorEndpoint() {
|
|
return `/users/me/authenticators/${this.props.params.authId}/`;
|
|
}
|
|
|
|
get enrollEndpoint() {
|
|
return `${this.authenticatorEndpoint}enroll/`;
|
|
}
|
|
|
|
getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
|
|
const errorHandler = (err: any) => {
|
|
const alreadyEnrolled =
|
|
err &&
|
|
err.status === 400 &&
|
|
err.responseJSON &&
|
|
err.responseJSON.details === 'Already enrolled';
|
|
|
|
if (alreadyEnrolled) {
|
|
this.props.router.push('/settings/account/security/');
|
|
addErrorMessage(t('Already enrolled'));
|
|
}
|
|
|
|
// Allow the endpoint to fail if the user is already enrolled
|
|
return alreadyEnrolled;
|
|
};
|
|
|
|
return [['authenticator', this.enrollEndpoint, {}, {allowError: errorHandler}]];
|
|
}
|
|
|
|
componentDidMount() {
|
|
super.componentDidMount();
|
|
this.pendingInvitation = getPendingInvite();
|
|
}
|
|
|
|
pendingInvitation: PendingInvite = null;
|
|
|
|
get authenticatorName() {
|
|
return this.state.authenticator?.name ?? 'Authenticator';
|
|
}
|
|
|
|
// This resets state so that user can re-enter their phone number again
|
|
handleSmsReset = () => this.setState({hasSentCode: false}, this.remountComponent);
|
|
|
|
// Handles SMS authenticators
|
|
handleSmsSubmit = async (dataModel: any) => {
|
|
const {authenticator, hasSentCode} = this.state;
|
|
const {phone, otp} = dataModel;
|
|
|
|
// Don't submit if empty
|
|
if (!phone || !authenticator) {
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
phone,
|
|
// Make sure `otp` is undefined if we are submitting OTP verification
|
|
// Otherwise API will think that we are on verification step (e.g. after submitting phone)
|
|
otp: hasSentCode ? otp : undefined,
|
|
secret: authenticator.secret,
|
|
};
|
|
|
|
// Only show loading when submitting OTP
|
|
this.setState({sendingCode: !hasSentCode});
|
|
|
|
if (!hasSentCode) {
|
|
addLoadingMessage(t('Sending code to %s...', data.phone));
|
|
} else {
|
|
addLoadingMessage(t('Verifying OTP...'));
|
|
}
|
|
|
|
try {
|
|
await this.api.requestPromise(this.enrollEndpoint, {data});
|
|
} catch (error) {
|
|
this.formModel.resetForm();
|
|
|
|
addErrorMessage(
|
|
this.state.hasSentCode ? t('Incorrect OTP') : t('Error sending SMS')
|
|
);
|
|
|
|
this.setState({
|
|
hasSentCode: false,
|
|
sendingCode: false,
|
|
});
|
|
|
|
// Re-mount because we want to fetch a fresh secret
|
|
this.remountComponent();
|
|
|
|
return;
|
|
}
|
|
|
|
if (!hasSentCode) {
|
|
// Just successfully finished sending OTP to user
|
|
this.setState({hasSentCode: true, sendingCode: false});
|
|
addSuccessMessage(t('Sent code to %s', data.phone));
|
|
} else {
|
|
// OTP was accepted and SMS was added as a 2fa method
|
|
this.handleEnrollSuccess();
|
|
}
|
|
};
|
|
|
|
// Handle u2f device tap
|
|
handleU2fTap = async (tapData: any) => {
|
|
const data = {deviceName: this.formModel.getValue('deviceName'), ...tapData};
|
|
|
|
this.setState({loading: true});
|
|
|
|
try {
|
|
await this.api.requestPromise(this.enrollEndpoint, {data});
|
|
} catch (err) {
|
|
this.handleEnrollError();
|
|
return;
|
|
}
|
|
|
|
this.handleEnrollSuccess();
|
|
};
|
|
|
|
// Currently only TOTP uses this
|
|
handleTotpSubmit = async (dataModel: any) => {
|
|
if (!this.state.authenticator) {
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
...(dataModel ?? {}),
|
|
secret: this.state.authenticator.secret,
|
|
};
|
|
|
|
this.setState({loading: true});
|
|
|
|
try {
|
|
await this.api.requestPromise(this.enrollEndpoint, {method: 'POST', data});
|
|
} catch (err) {
|
|
this.handleEnrollError();
|
|
return;
|
|
}
|
|
|
|
this.handleEnrollSuccess();
|
|
};
|
|
|
|
handleSubmit: FormProps['onSubmit'] = data => {
|
|
const id = this.state.authenticator?.id;
|
|
|
|
if (id === 'totp') {
|
|
this.handleTotpSubmit(data);
|
|
return;
|
|
}
|
|
if (id === 'sms') {
|
|
this.handleSmsSubmit(data);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Handler when we successfully add a 2fa device
|
|
async handleEnrollSuccess() {
|
|
// If we're pending approval of an invite, the user will have just joined
|
|
// the organization when completing 2fa enrollment. We should reload the
|
|
// organization context in that case to assign them to the org.
|
|
if (this.pendingInvitation) {
|
|
await fetchOrganizationByMember(
|
|
this.api,
|
|
this.pendingInvitation.memberId.toString(),
|
|
{
|
|
addOrg: true,
|
|
fetchOrgDetails: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
this.props.router.push('/settings/account/security/');
|
|
openRecoveryOptions({authenticatorName: this.authenticatorName});
|
|
}
|
|
|
|
// Handler when we failed to add a 2fa device
|
|
handleEnrollError() {
|
|
this.setState({loading: false});
|
|
addErrorMessage(t('Error adding %s authenticator', this.authenticatorName));
|
|
}
|
|
|
|
// Removes an authenticator
|
|
handleRemove = async () => {
|
|
const {authenticator} = this.state;
|
|
|
|
if (!authenticator || !authenticator.authId) {
|
|
return;
|
|
}
|
|
|
|
// `authenticator.authId` is NOT the same as `props.params.authId` This is
|
|
// for backwards compatibility with API endpoint
|
|
try {
|
|
await this.api.requestPromise(this.authenticatorEndpoint, {method: 'DELETE'});
|
|
} catch (err) {
|
|
addErrorMessage(t('Error removing authenticator'));
|
|
return;
|
|
}
|
|
|
|
this.props.router.push('/settings/account/security/');
|
|
addSuccessMessage(t('Authenticator has been removed'));
|
|
};
|
|
|
|
renderBody() {
|
|
const {authenticator, hasSentCode, sendingCode} = this.state;
|
|
|
|
if (!authenticator) {
|
|
return null;
|
|
}
|
|
|
|
const fields = getFields({
|
|
authenticator,
|
|
hasSentCode,
|
|
sendingCode,
|
|
onSmsReset: this.handleSmsReset,
|
|
onU2fTap: this.handleU2fTap,
|
|
});
|
|
|
|
// Attempt to extract `defaultValue` from server generated form fields
|
|
const defaultValues = fields
|
|
? fields
|
|
.filter(
|
|
field =>
|
|
typeof field !== 'function' && typeof field.defaultValue !== 'undefined'
|
|
)
|
|
.map(field => [
|
|
field.name,
|
|
typeof field !== 'function' ? field.defaultValue : '',
|
|
])
|
|
.reduce((acc, [name, value]) => {
|
|
acc[name] = value;
|
|
return acc;
|
|
}, {})
|
|
: {};
|
|
|
|
const isActive = authenticator.isEnrolled || authenticator.status === 'rotation';
|
|
|
|
return (
|
|
<Fragment>
|
|
<SettingsPageHeader
|
|
title={
|
|
<Fragment>
|
|
<span>{authenticator.name}</span>
|
|
<CircleIndicator
|
|
role="status"
|
|
aria-label={
|
|
isActive
|
|
? t('Authentication Method Active')
|
|
: t('Authentication Method Inactive')
|
|
}
|
|
enabled={isActive}
|
|
css={{marginLeft: 6}}
|
|
/>
|
|
</Fragment>
|
|
}
|
|
action={
|
|
authenticator.isEnrolled &&
|
|
authenticator.removeButton && (
|
|
<RemoveConfirm onConfirm={this.handleRemove}>
|
|
<Button priority="danger">{authenticator.removeButton}</Button>
|
|
</RemoveConfirm>
|
|
)
|
|
}
|
|
/>
|
|
|
|
<TextBlock>{authenticator.description}</TextBlock>
|
|
|
|
{authenticator.rotationWarning && authenticator.status === 'rotation' && (
|
|
<Alert type="warning" showIcon>
|
|
{authenticator.rotationWarning}
|
|
</Alert>
|
|
)}
|
|
|
|
{!!authenticator.form?.length && (
|
|
<Form
|
|
model={this.formModel}
|
|
apiMethod="POST"
|
|
apiEndpoint={this.authenticatorEndpoint}
|
|
onSubmit={this.handleSubmit}
|
|
initialData={{...defaultValues, ...authenticator}}
|
|
hideFooter
|
|
>
|
|
<JsonForm forms={[{title: 'Configuration', fields: fields ?? []}]} />
|
|
</Form>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const CodeContainer = styled(PanelItem)`
|
|
justify-content: center;
|
|
`;
|
|
|
|
const Actions = styled(PanelItem)`
|
|
justify-content: flex-end;
|
|
`;
|
|
|
|
const StyledQRCode = styled(QRCodeCanvas)`
|
|
background: white;
|
|
padding: ${space(2)};
|
|
`;
|
|
|
|
export default withSentryRouter(AccountSecurityEnroll);
|