mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
403 lines
11 KiB
TypeScript
403 lines
11 KiB
TypeScript
import {Fragment, useState} from 'react';
|
|
import styled from '@emotion/styled';
|
|
|
|
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
|
|
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
|
|
import type {Client} from 'sentry/api';
|
|
import {Alert} from 'sentry/components/alert';
|
|
import {Button} from 'sentry/components/button';
|
|
import ButtonBar from 'sentry/components/buttonBar';
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
import {DEFAULT_TOAST_DURATION} from 'sentry/constants';
|
|
import {t, tct} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization, Project} from 'sentry/types';
|
|
import type {
|
|
AppStoreConnectStatusData,
|
|
CustomRepoAppStoreConnect,
|
|
} from 'sentry/types/debugFiles';
|
|
import {unexpectedErrorMessage} from 'sentry/utils/appStoreValidationErrorMessage';
|
|
import withApi from 'sentry/utils/withApi';
|
|
|
|
import StepOne from './stepOne';
|
|
import StepTwo from './stepTwo';
|
|
import type {AppStoreApp, StepOneData, StepTwoData} from './types';
|
|
import {getAppStoreErrorMessage} from './utils';
|
|
|
|
type Props = Pick<ModalRenderProps, 'Header' | 'Body' | 'Footer'> & {
|
|
api: Client;
|
|
onSubmit: () => void;
|
|
orgSlug: Organization['slug'];
|
|
projectSlug: Project['slug'];
|
|
appStoreConnectStatusData?: AppStoreConnectStatusData;
|
|
initialData?: CustomRepoAppStoreConnect;
|
|
};
|
|
|
|
const steps = [t('App Store Connect credentials'), t('Choose an application')];
|
|
|
|
function AppStoreConnect({
|
|
Header,
|
|
Body,
|
|
Footer,
|
|
api,
|
|
initialData,
|
|
orgSlug,
|
|
projectSlug,
|
|
onSubmit,
|
|
appStoreConnectStatusData,
|
|
}: Props) {
|
|
const {credentials} = appStoreConnectStatusData ?? {};
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [activeStep, setActiveStep] = useState(0);
|
|
const [appStoreApps, setAppStoreApps] = useState<AppStoreApp[]>([]);
|
|
|
|
const [stepOneData, setStepOneData] = useState<StepOneData>({
|
|
issuer: initialData?.appconnectIssuer,
|
|
keyId: initialData?.appconnectKey,
|
|
privateKey: typeof initialData?.appconnectPrivateKey === 'object' ? undefined : '',
|
|
errors: undefined,
|
|
});
|
|
|
|
const [stepTwoData, setStepTwoData] = useState<StepTwoData>({
|
|
app: undefined,
|
|
});
|
|
|
|
async function checkCredentials() {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await api.requestPromise(
|
|
`/projects/${orgSlug}/${projectSlug}/appstoreconnect/apps/`,
|
|
{
|
|
method: 'POST',
|
|
data: {
|
|
id: stepOneData.privateKey !== undefined ? undefined : initialData?.id,
|
|
appconnectIssuer: stepOneData.issuer,
|
|
appconnectKey: stepOneData.keyId,
|
|
appconnectPrivateKey: stepOneData.privateKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
const storeApps: AppStoreApp[] = response.apps;
|
|
|
|
if (!!initialData && !storeApps.find(app => app.appId === initialData.appId)) {
|
|
addErrorMessage(t('Credentials not authorized for this application'));
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setAppStoreApps(storeApps);
|
|
|
|
if (
|
|
stepTwoData.app?.appId &&
|
|
!storeApps.find(app => app.appId === stepTwoData.app?.appId)
|
|
) {
|
|
setStepTwoData({app: storeApps[0]});
|
|
}
|
|
|
|
if (initialData) {
|
|
updateCredentials();
|
|
return;
|
|
}
|
|
|
|
setIsLoading(false);
|
|
goNext();
|
|
} catch (error) {
|
|
setIsLoading(false);
|
|
const appStoreConnnectError = getAppStoreErrorMessage(error);
|
|
if (typeof appStoreConnnectError === 'string') {
|
|
// app-connect-authentication-error
|
|
// app-connect-forbidden-error
|
|
addErrorMessage(appStoreConnnectError);
|
|
return;
|
|
}
|
|
setStepOneData({...stepOneData, errors: appStoreConnnectError});
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
setTimeout(() => onSubmit(), DEFAULT_TOAST_DURATION);
|
|
}
|
|
|
|
async function updateCredentials() {
|
|
if (!initialData) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.requestPromise(
|
|
`/projects/${orgSlug}/${projectSlug}/appstoreconnect/${initialData.id}/`,
|
|
{
|
|
method: 'POST',
|
|
data: {
|
|
appconnectIssuer: stepOneData.issuer,
|
|
appconnectKey: stepOneData.keyId,
|
|
appconnectPrivateKey: stepOneData.privateKey,
|
|
appName: initialData.appName,
|
|
appId: initialData.appId,
|
|
bundleId: initialData.bundleId,
|
|
},
|
|
}
|
|
);
|
|
|
|
addSuccessMessage(t('Successfully updated custom repository'));
|
|
closeModal();
|
|
} catch (error) {
|
|
setIsLoading(false);
|
|
const appStoreConnnectError = getAppStoreErrorMessage(error);
|
|
|
|
if (typeof appStoreConnnectError === 'string') {
|
|
if (appStoreConnnectError === unexpectedErrorMessage) {
|
|
addErrorMessage(t('An error occurred while updating the custom repository'));
|
|
return;
|
|
}
|
|
addErrorMessage(appStoreConnnectError);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function persistData() {
|
|
if (!stepTwoData.app) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await api.requestPromise(`/projects/${orgSlug}/${projectSlug}/appstoreconnect/`, {
|
|
method: 'POST',
|
|
data: {
|
|
appconnectIssuer: stepOneData.issuer,
|
|
appconnectKey: stepOneData.keyId,
|
|
appconnectPrivateKey: stepOneData.privateKey,
|
|
appName: stepTwoData.app.name,
|
|
appId: stepTwoData.app.appId,
|
|
bundleId: stepTwoData.app.bundleId,
|
|
},
|
|
});
|
|
|
|
addSuccessMessage(t('Successfully added custom repository'));
|
|
closeModal();
|
|
} catch (error) {
|
|
setIsLoading(false);
|
|
const appStoreConnnectError = getAppStoreErrorMessage(error);
|
|
|
|
if (typeof appStoreConnnectError === 'string') {
|
|
if (appStoreConnnectError === unexpectedErrorMessage) {
|
|
addErrorMessage(t('An error occurred while adding the custom repository'));
|
|
return;
|
|
}
|
|
addErrorMessage(appStoreConnnectError);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isFormInvalid() {
|
|
switch (activeStep) {
|
|
case 0:
|
|
return Object.keys(stepOneData).some(key => {
|
|
if (key === 'errors') {
|
|
const errors = stepOneData[key] ?? {};
|
|
return Object.keys(errors).some(error => !!errors[error]);
|
|
}
|
|
|
|
if (key === 'privateKey' && stepOneData[key] === undefined) {
|
|
return false;
|
|
}
|
|
|
|
return !stepOneData[key];
|
|
});
|
|
case 1:
|
|
return Object.keys(stepTwoData).some(key => !stepTwoData[key]);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function goNext() {
|
|
setActiveStep(activeStep + 1);
|
|
}
|
|
|
|
function handleGoBack() {
|
|
const newActiveStep = activeStep - 1;
|
|
setActiveStep(newActiveStep);
|
|
}
|
|
|
|
function handleGoNext() {
|
|
switch (activeStep) {
|
|
case 0:
|
|
checkCredentials();
|
|
break;
|
|
case 1:
|
|
persistData();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function renderCurrentStep() {
|
|
switch (activeStep) {
|
|
case 0:
|
|
return <StepOne stepOneData={stepOneData} onSetStepOneData={setStepOneData} />;
|
|
case 1:
|
|
return (
|
|
<StepTwo
|
|
appStoreApps={appStoreApps}
|
|
stepTwoData={stepTwoData}
|
|
onSetStepTwoData={setStepTwoData}
|
|
/>
|
|
);
|
|
default:
|
|
return (
|
|
<Alert type="error" showIcon>
|
|
{t('This step could not be found.')}
|
|
</Alert>
|
|
);
|
|
}
|
|
}
|
|
|
|
function getAlerts() {
|
|
const alerts: React.ReactElement[] = [];
|
|
|
|
if (activeStep !== 0) {
|
|
return alerts;
|
|
}
|
|
|
|
if (credentials?.status === 'invalid') {
|
|
alerts.push(
|
|
<StyledAlert type="warning" showIcon>
|
|
{credentials.code === 'app-connect-forbidden-error'
|
|
? t(
|
|
'Your App Store Connect credentials have insufficient permissions. To reconnect, update your credentials.'
|
|
)
|
|
: t(
|
|
'Your App Store Connect credentials are invalid. To reconnect, update your credentials.'
|
|
)}
|
|
</StyledAlert>
|
|
);
|
|
}
|
|
|
|
return alerts;
|
|
}
|
|
|
|
function renderBodyContent() {
|
|
const alerts = getAlerts();
|
|
|
|
return (
|
|
<Fragment>
|
|
{!!alerts.length && (
|
|
<Alerts>
|
|
{alerts.map((alert, index) => (
|
|
<Fragment key={index}>{alert}</Fragment>
|
|
))}
|
|
</Alerts>
|
|
)}
|
|
{renderCurrentStep()}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
if (initialData && !appStoreConnectStatusData) {
|
|
return <LoadingIndicator />;
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<Header closeButton>
|
|
<HeaderContent>
|
|
<NumericSymbol>{activeStep + 1}</NumericSymbol>
|
|
<HeaderContentTitle>{steps[activeStep]}</HeaderContentTitle>
|
|
<StepsOverview>
|
|
{tct('[currentStep] of [totalSteps]', {
|
|
currentStep: activeStep + 1,
|
|
totalSteps: initialData ? 1 : steps.length,
|
|
})}
|
|
</StepsOverview>
|
|
</HeaderContent>
|
|
</Header>
|
|
<Body>{renderBodyContent()}</Body>
|
|
<Footer>
|
|
<ButtonBar gap={1}>
|
|
{activeStep !== 0 && <Button onClick={handleGoBack}>{t('Back')}</Button>}
|
|
<StyledButton
|
|
priority="primary"
|
|
onClick={handleGoNext}
|
|
disabled={isLoading || isFormInvalid()}
|
|
>
|
|
{isLoading && (
|
|
<LoadingIndicatorWrapper>
|
|
<LoadingIndicator mini />
|
|
</LoadingIndicatorWrapper>
|
|
)}
|
|
{initialData
|
|
? t('Update')
|
|
: activeStep + 1 === steps.length
|
|
? t('Save')
|
|
: steps[activeStep + 1]}
|
|
</StyledButton>
|
|
</ButtonBar>
|
|
</Footer>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
export default withApi(AppStoreConnect);
|
|
|
|
const HeaderContent = styled('div')`
|
|
display: grid;
|
|
grid-template-columns: max-content max-content 1fr;
|
|
align-items: center;
|
|
gap: ${space(1)};
|
|
`;
|
|
|
|
const NumericSymbol = styled('div')`
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
font-weight: 700;
|
|
font-size: ${p => p.theme.fontSizeMedium};
|
|
background-color: ${p => p.theme.yellow300};
|
|
`;
|
|
|
|
const HeaderContentTitle = styled('div')`
|
|
font-weight: 700;
|
|
font-size: ${p => p.theme.fontSizeExtraLarge};
|
|
`;
|
|
|
|
const StepsOverview = styled('div')`
|
|
color: ${p => p.theme.gray300};
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
`;
|
|
|
|
const LoadingIndicatorWrapper = styled('div')`
|
|
height: 100%;
|
|
position: absolute;
|
|
width: 100%;
|
|
top: 0;
|
|
left: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
`;
|
|
|
|
const StyledButton = styled(Button)`
|
|
position: relative;
|
|
`;
|
|
|
|
const Alerts = styled('div')`
|
|
display: grid;
|
|
gap: ${space(1.5)};
|
|
margin-bottom: ${space(3)};
|
|
`;
|
|
|
|
const StyledAlert = styled(Alert)`
|
|
margin: 0;
|
|
`;
|