Skip to content

Commit 82d7044

Browse files
authored
ARC 2495 [BE] - Deferred Installation (#2507)
* - Deferred routes added * - Fixing minor issue * - Updating error code for insufficient permissions * - Routes cleanup - Test cases added * - Updated snapshots * - Fixing test case * - Removing JWT check from OAuth route - Fixing test cases * - Review changes * - Updating teste cases * - Restricted info for the deferred-request-parse.ts * ARC 2495 [FE] - Deferred Installation (#2508) * - Adding the FE views * - WIP * - Finishing * - Minor changes * - Cleanup * - Cleanup * - Cleanup * - Skip JWT for deferred path * - Correcting URL * - Removed unwanted log * - Fixing test cases * - Review changes * - Minor change corresponding to the removal of 5Ku FF * ARC 2554 - Add analytics for the deferred installation flow (#2518) * - Added events * - Copy events added * - Added new events for deferredInstallationRoute, outside of iframe * - Updated source to main * - Cleanup * - Fixing snapshots
1 parent d292dcb commit 82d7044

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1222
-190
lines changed

spa/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@
3131
"@atlaskit/heading": "^1.3.7",
3232
"@atlaskit/icon": "^21.12.4",
3333
"@atlaskit/lozenge": "^11.4.3",
34+
"@atlaskit/modal-dialog": "^12.8.3",
3435
"@atlaskit/page-header": "^10.4.4",
3536
"@atlaskit/select": "^16.5.7",
3637
"@atlaskit/skeleton": "^0.2.3",
38+
"@atlaskit/spinner": "^15.6.1",
39+
"@atlaskit/textarea": "^4.7.7",
3740
"@atlaskit/tokens": "^1.11.1",
3841
"@atlaskit/tooltip": "^17.8.3",
3942
"@emotion/styled": "^11.11.0",
+30-20
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
11
import { AnalyticClient, ScreenEventProps, TrackEventProps, UIEventProps } from "./types";
2-
import { axiosRest } from "../api/axiosInstance";
2+
import { axiosRest, axiosRestWithNoJwt } from "../api/axiosInstance";
33
import { reportError } from "../utils";
4-
const sendAnalytics = (eventType: string, eventProperties: Record<string, unknown>, eventAttributes?: Record<string, unknown>) => {
5-
axiosRest.post(`/rest/app/cloud/analytics-proxy`,
6-
{
7-
eventType,
8-
eventProperties,
9-
eventAttributes
10-
}).catch(e => {
11-
reportError(e, {
12-
path: "sendAnalytics",
13-
eventType,
14-
...eventProperties,
15-
...eventAttributes
4+
const sendAnalytics = (eventType: string, eventProperties: Record<string, unknown>, eventAttributes?: Record<string, unknown>, requestId?: string) => {
5+
const eventData = {
6+
eventType,
7+
eventProperties,
8+
eventAttributes
9+
};
10+
const eventError = {
11+
path: "sendAnalytics",
12+
eventType,
13+
...eventProperties,
14+
...eventAttributes
15+
};
16+
17+
if (requestId) {
18+
axiosRestWithNoJwt.post(`/rest/app/cloud/deferred/analytics-proxy/${requestId}`, eventData)
19+
.catch(e => {
20+
reportError(e, eventError);
1621
});
17-
});
22+
} else {
23+
axiosRest.post(`/rest/app/cloud/analytics-proxy`, eventData)
24+
.catch(e => {
25+
reportError(e, eventError);
26+
});
27+
}
1828
};
1929
export const analyticsProxyClient: AnalyticClient = {
20-
sendScreenEvent: function(eventProps: ScreenEventProps, attributes?: Record<string, unknown>) {
21-
sendAnalytics("screen", eventProps, attributes);
30+
sendScreenEvent: function(eventProps: ScreenEventProps, attributes?: Record<string, unknown>, requestId?: string) {
31+
sendAnalytics("screen", eventProps, attributes, requestId);
2232
},
23-
sendUIEvent: function (eventProps: UIEventProps, attributes?: Record<string, unknown>) {
33+
sendUIEvent: function (eventProps: UIEventProps, attributes?: Record<string, unknown>, requestId?: string) {
2434
sendAnalytics("ui", {
2535
...eventProps,
2636
source: "spa"
27-
}, attributes);
37+
}, attributes, requestId);
2838
},
29-
sendTrackEvent: function (eventProps: TrackEventProps, attributes?: Record<string, unknown>) {
39+
sendTrackEvent: function (eventProps: TrackEventProps, attributes?: Record<string, unknown>, requestId?: string) {
3040
sendAnalytics("track", {
3141
...eventProps,
3242
source: "spa"
33-
}, attributes);
43+
}, attributes, requestId);
3444
}
3545
};

spa/src/analytics/types.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ type UIEventActionSubject =
55
| "connectOrganisation" | "installToNewOrganisation"
66
| "checkBackfillStatus"
77
| "dropExperienceViaBackButton"
8-
| "checkOrgAdmin"
9-
| "learnAboutIssueLinking" | "learnAboutDevelopmentWork";
8+
| "learnAboutIssueLinking" | "learnAboutDevelopmentWork"
9+
| "checkOrgAdmin" | "generateDeferredInstallationLink"
10+
| "closedDeferredInstallationModal" | "copiedDeferredInstallationUrl"
11+
| "signInAndConnectThroughDeferredInstallationStartScreen";
1012

1113
export type UIEventProps = {
1214
actionSubject: UIEventActionSubject,
@@ -17,7 +19,11 @@ export type ScreenNames =
1719
"StartConnectionEntryScreen"
1820
| "AuthorisationScreen"
1921
| "OrganisationConnectionScreen"
20-
| "SuccessfulConnectedScreen";
22+
| "SuccessfulConnectedScreen"
23+
| "DeferredInstallationModal"
24+
| "DeferredInstallationStartScreen"
25+
| "DeferredInstallationFailedScreen"
26+
| "DeferredInstallationSuccessScreen";
2127

2228
type TrackEventActionSubject =
2329
"finishOAuthFlow"
@@ -35,8 +41,8 @@ export type ScreenEventProps = {
3541
};
3642

3743
export type AnalyticClient = {
38-
sendScreenEvent: (eventProps: ScreenEventProps, attributes?: Record<string, unknown>) => void;
39-
sendUIEvent: (eventProps: UIEventProps, attributes?: Record<string, unknown>) => void;
40-
sendTrackEvent: (eventProps: TrackEventProps, attributes?: Record<string, unknown>) => void;
44+
sendScreenEvent: (eventProps: ScreenEventProps, attributes?: Record<string, unknown>, requestId?: string) => void;
45+
sendUIEvent: (eventProps: UIEventProps, attributes?: Record<string, unknown>, requestId?: string) => void;
46+
sendTrackEvent: (eventProps: TrackEventProps, attributes?: Record<string, unknown>, requestId?: string) => void;
4147
};
4248

spa/src/api/auth/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { GetRedirectUrlResponse, ExchangeTokenResponse } from "rest-interfaces";
2-
import { axiosRest } from "../axiosInstance";
2+
import { axiosRestWithNoJwt } from "../axiosInstance";
33

44
export default {
5-
generateOAuthUrl: () => axiosRest.get<GetRedirectUrlResponse>("/rest/app/cloud/oauth/redirectUrl"),
6-
exchangeToken: (code: string, state: string) => axiosRest.post<ExchangeTokenResponse>("/rest/app/cloud/oauth/exchangeToken", { code, state }),
5+
generateOAuthUrl: () => axiosRestWithNoJwt.get<GetRedirectUrlResponse>("/rest/app/cloud/oauth/redirectUrl"),
6+
exchangeToken: (code: string, state: string) => axiosRestWithNoJwt.post<ExchangeTokenResponse>("/rest/app/cloud/oauth/exchangeToken", { code, state }),
77
};

spa/src/api/axiosInstance.ts

+11
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getJiraJWT } from "../utils";
33

44
const THIRTY_SECONDS_IN_MS = 30_000;
55

6+
const axiosRestWithNoJwt = axios.create({ timeout: THIRTY_SECONDS_IN_MS });
67
const axiosRest = axios.create({
78
timeout: THIRTY_SECONDS_IN_MS
89
});
@@ -47,9 +48,19 @@ axiosRestWithGitHubToken.interceptors.request.use(async (config) => {
4748
return config;
4849
});
4950

51+
const axiosRestWithNoJwtButWithGitHubToken = axios.create({
52+
timeout: THIRTY_SECONDS_IN_MS
53+
});
54+
axiosRestWithNoJwtButWithGitHubToken.interceptors.request.use(async (config) => {
55+
config.headers["github-auth"] = gitHubToken;
56+
return config;
57+
});
58+
5059
export {
5160
axiosGitHub,
5261
axiosRest,
62+
axiosRestWithNoJwt,
63+
axiosRestWithNoJwtButWithGitHubToken,
5364
axiosRestWithGitHubToken,
5465
clearGitHubToken,
5566
setGitHubToken,

spa/src/api/deferral/index.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {
2+
DeferralParsedRequest,
3+
DeferredInstallationUrlParams,
4+
GetDeferredInstallationUrl
5+
} from "rest-interfaces";
6+
import { axiosRest, axiosRestWithNoJwt, axiosRestWithNoJwtButWithGitHubToken } from "../axiosInstance";
7+
8+
export default {
9+
parseDeferredRequestId: (requestId: string) => axiosRestWithNoJwt.get<DeferralParsedRequest>(`/rest/app/cloud/deferred/parse/${requestId}`),
10+
getDeferredInstallationUrl: (params: DeferredInstallationUrlParams) =>
11+
axiosRest.get<GetDeferredInstallationUrl>("/rest/app/cloud/deferred/installation-url", { params }),
12+
connectDeferredOrg: (requestId: string) => axiosRestWithNoJwtButWithGitHubToken.post(`/rest/app/cloud/deferred/connect/${requestId}`)
13+
};
14+

spa/src/api/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import App from "./apps";
44
import Orgs from "./orgs";
55
import GitHub from "./github";
66
import Subscription from "./subscriptions";
7+
import Deferral from "./deferral";
78

89
const ApiRequest = {
910
token: Token,
1011
auth: Auth,
1112
gitHub: GitHub,
1213
app: App,
1314
orgs: Orgs,
15+
deferral: Deferral,
1416
subscriptions: Subscription,
1517
};
1618

spa/src/app.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import StartConnection from "./pages/StartConnection";
88
import ConfigSteps from "./pages/ConfigSteps";
99
import Connected from "./pages/Connected";
1010
import InstallationRequested from "./pages/InstallationRequested";
11+
import DeferredInstallation from "./pages/DeferredInstallation";
1112
import Connections from "./pages/Connections";
1213

1314
import * as Sentry from "@sentry/react";
@@ -36,6 +37,7 @@ const App = () => {
3637
<Route path="steps" element={<ConfigSteps/>}/>
3738
<Route path="connected" element={<Connected />}/>
3839
<Route path="installationRequested" element={<InstallationRequested />}/>
40+
<Route path="deferred" element={<DeferredInstallation />}/>
3941
</Route>
4042
</SentryRoutes>
4143
</BrowserRouter>

spa/src/common/Wrapper.tsx

+17-14
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,26 @@ const wrapperCenterStyle = css`
1818
justify-content: center;
1919
`;
2020

21-
const navigateToHomePage = () => {
22-
analyticsClient.sendUIEvent({ actionSubject: "dropExperienceViaBackButton", action: "clicked" });
23-
AP.getLocation((location: string) => {
24-
const locationUrl = new URL(location);
25-
AP.navigator.go( "site", { absoluteUrl: `${locationUrl.origin}/jira/marketplace/discover/app/com.github.integration.production` });
26-
});
27-
};
21+
export const Wrapper = (attr: { hideClosedBtn?: boolean, children?: ReactNode | undefined }) => {
22+
const navigateToHomePage = () => {
23+
analyticsClient.sendUIEvent({ actionSubject: "dropExperienceViaBackButton", action: "clicked" });
24+
AP.getLocation((location: string) => {
25+
const locationUrl = new URL(location);
26+
AP.navigator.go( "site", { absoluteUrl: `${locationUrl.origin}/jira/marketplace/discover/app/com.github.integration.production` });
27+
});
28+
};
2829

29-
export const Wrapper = (attr: { children?: ReactNode | undefined }) => {
3030
return (
3131
<div css={wrapperStyle}>
32-
<Button
33-
style={{ float: "right" }}
34-
iconBefore={<CrossIcon label="Close" size="medium" />}
35-
appearance="subtle"
36-
onClick={navigateToHomePage}
37-
/>
32+
{
33+
!attr.hideClosedBtn && <Button
34+
style={{ float: "right" }}
35+
iconBefore={<CrossIcon label="Close" size="medium" />}
36+
appearance="subtle"
37+
onClick={navigateToHomePage}
38+
/>
39+
}
40+
3841
<div css={wrapperCenterStyle}>{attr.children}</div>
3942
</div>
4043
);

spa/src/components/Error/KnownErrors/index.tsx

+122-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
/** @jsxImportSource @emotion/react */
2+
import { useState } from "react";
23
import { css } from "@emotion/react";
34
import { token } from "@atlaskit/tokens";
45
import analyticsClient from "../../../analytics";
56
import { popup } from "../../../utils";
7+
import { CheckAdminOrgSource, DeferredInstallationUrlParams } from "rest-interfaces";
8+
import { HostUrlType } from "../../../utils/modifyError";
9+
import Api from "../../../api";
10+
import Modal, {
11+
ModalBody,
12+
ModalFooter,
13+
ModalHeader,
14+
ModalTitle,
15+
ModalTransition,
16+
} from "@atlaskit/modal-dialog";
17+
import TextArea from "@atlaskit/textarea";
18+
import Spinner from "@atlaskit/spinner";
19+
import Button from "@atlaskit/button";
620

21+
const olStyle = css`
22+
padding-left: 1.2em;
23+
`;
724
const paragraphStyle = css`
825
color: ${token("color.text.subtle")};
926
`;
@@ -15,6 +32,9 @@ const linkStyle = css`
1532
padding-left: 0;
1633
padding-right: 0;
1734
`;
35+
const textAreaStyle = css`
36+
margin-top: 20px;
37+
`;
1838

1939
/************************************************************************
2040
* UI view for the 3 known errors
@@ -39,14 +59,108 @@ export const ErrorForSSO = ({ orgName, accessUrl, resetCallback, onPopupBlocked
3959
</div>
4060
</>;
4161

42-
export const ErrorForNonAdmins = ({ orgName, adminOrgsUrl }: { orgName?: string; adminOrgsUrl: string; }) => <div css={paragraphStyle}>
43-
Can't connect, you're not the organization owner{orgName && <span> of <b>{orgName}</b></span>}.<br />
44-
Ask an <a css={linkStyle} onClick={() => {
45-
// TODO: Need to get this URL for Enterprise users too, this is only for Cloud users
46-
popup(adminOrgsUrl);
47-
analyticsClient.sendUIEvent({ actionSubject: "checkOrgAdmin", action: "clicked"}, { type: "cloud" });
48-
}}>organization owner</a> to complete this step.
49-
</div>;
62+
export const ErrorForNonAdmins = ({ orgName, adminOrgsUrl, onPopupBlocked, deferredInstallationOrgDetails , hostUrl}: {
63+
orgName?: string;
64+
adminOrgsUrl: string;
65+
onPopupBlocked: () => void;
66+
deferredInstallationOrgDetails: DeferredInstallationUrlParams;
67+
hostUrl?: HostUrlType;
68+
}) => {
69+
const [isOpen, setIsOpen] = useState<boolean>(false);
70+
const [isLoading, setIsLoading] = useState<boolean>(false);
71+
const [deferredInstallationUrl, setDeferredInstallationUrl] = useState<string | null>(null);
72+
73+
const getOrgOwnerUrl = async (from: CheckAdminOrgSource) => {
74+
// TODO: Need to get this URL for Enterprise users too, this is only for Cloud users
75+
const win = popup(adminOrgsUrl);
76+
if (win === null) onPopupBlocked();
77+
analyticsClient.sendUIEvent({ actionSubject: "checkOrgAdmin", action: "clicked"}, { type: "cloud", from });
78+
};
79+
80+
const getDeferredInstallationUrl = async () => {
81+
if (!isOpen) {
82+
analyticsClient.sendScreenEvent({ name: "DeferredInstallationModal" }, { type: "cloud" });
83+
try {
84+
setIsOpen(true);
85+
setIsLoading(true);
86+
const response = await Api.deferral.getDeferredInstallationUrl({
87+
gitHubInstallationId: deferredInstallationOrgDetails?.gitHubInstallationId ,
88+
gitHubOrgName: deferredInstallationOrgDetails?.gitHubOrgName
89+
});
90+
setDeferredInstallationUrl(response.data.deferredInstallUrl);
91+
analyticsClient.sendUIEvent({ actionSubject: "generateDeferredInstallationLink", action: "clicked"}, { type: "cloud" });
92+
} catch(e) {
93+
// TODO: handle this error in UI/Modal ?
94+
console.error("Could not fetch the deferred installation url: ", e);
95+
} finally {
96+
setIsLoading(false);
97+
}
98+
}
99+
};
100+
101+
const closeModal = () => {
102+
setIsOpen(false);
103+
setDeferredInstallationUrl(null);
104+
analyticsClient.sendUIEvent({ actionSubject: "closedDeferredInstallationModal", action: "clicked"}, { type: "cloud" });
105+
};
106+
return (
107+
<div css={paragraphStyle}>
108+
You’re not an owner for this organization. To connect:
109+
<ol css={olStyle}>
110+
<li>
111+
<a css={linkStyle} onClick={() => getOrgOwnerUrl("ErrorInOrgList")}>
112+
Find an organization owner.
113+
</a>
114+
</li>
115+
<li>
116+
<a css={linkStyle} onClick={getDeferredInstallationUrl}>
117+
Send them a link and ask them to connect.
118+
</a>
119+
</li>
120+
</ol>
121+
<ModalTransition>
122+
{isOpen && (
123+
<Modal onClose={closeModal}>
124+
{isLoading ? (
125+
<Spinner interactionName="load" />
126+
) : (
127+
<>
128+
<ModalHeader>
129+
<ModalTitle>Send a link to an organization owner</ModalTitle>
130+
</ModalHeader>
131+
<ModalBody>
132+
<div css={paragraphStyle}>
133+
Copy the message and URL below, and send it to an
134+
organization owner to approve.
135+
<br />
136+
<a css={linkStyle} onClick={() => getOrgOwnerUrl("DeferredInstallationModal")}>
137+
Find an organization owner
138+
</a>
139+
</div>
140+
<TextArea
141+
onCopy={() => {
142+
analyticsClient.sendUIEvent({ actionSubject: "copiedDeferredInstallationUrl", action: "clicked"}, { type: "cloud" });
143+
}}
144+
css={textAreaStyle}
145+
id="deffered-installation-msg"
146+
name="deffered-installation-msg"
147+
defaultValue={`I want to connect the GitHub organization ${orgName} to the Jira site ${hostUrl?.jiraHost}, and I need your approval as an organization owner.\n\nIf you approve, can you go to this link and complete the connection?\n\n${deferredInstallationUrl}`}
148+
readOnly
149+
/>
150+
</ModalBody>
151+
<ModalFooter>
152+
<Button appearance="primary" onClick={closeModal} autoFocus>
153+
Close
154+
</Button>
155+
</ModalFooter>
156+
</>
157+
)}
158+
</Modal>
159+
)}
160+
</ModalTransition>
161+
</div>
162+
);
163+
};
50164

51165
export const ErrorForPopupBlocked = ({ onDismiss }: { onDismiss: () => void }) => (
52166
<>

0 commit comments

Comments
 (0)