Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/subworkflow-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ jobs:
if [ -n "${{ github.event.pull_request.number }}" ]; then
# Regular PR case
echo "PR number found: ${{ github.event.pull_request.number }}"
labels=$(jq -r '[.[] | .name] | join(",")' <<< '${{ toJson(github.event.pull_request.labels) }}')
json_labels='${{ toJson(github.event.pull_request.labels) }}'
labels=$(echo "$json_labels" | jq -r '[.[] | .name] | join(",")')
else
# Merge queue case - find PR by head ref
echo "No PR number found, checking for merge group"
Expand Down
8 changes: 4 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"isRequired": false,
"isBackendOnly": false,
"default": false,
"condition": "settings.use_advanced_connect == true",
"condition": "settings.use_advanced_connect != false",
"order": 2
},
"company_id": {
Expand All @@ -48,15 +48,15 @@
"type": "string",
"isRequired": false,
"isBackendOnly": false,
"condition": "settings.use_advanced_connect == true",
"condition": "settings.use_advanced_connect != false",
"order": 4
},
"client_secret": {
"title": "Client Secret",
"type": "string",
"isRequired": false,
"isBackendOnly": true,
"condition": "settings.use_advanced_connect == true",
"condition": "settings.use_advanced_connect != false",
"order": 5
},
"callback_url": {
Expand All @@ -65,7 +65,7 @@
"options": { "entrypoint": "#/admin/callback", "height": "80px" },
"isRequired": false,
"isBackendOnly": false,
"condition": "settings.use_advanced_connect == true",
"condition": "settings.use_advanced_connect != false",
"order": 6
}
},
Expand Down
57 changes: 27 additions & 30 deletions src/api/quickbooks/baseRequest/baseRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import refreshAccessToken from '../refreshAccessToken';
export default async function baseRequest<T>(client: IDeskproClient, reqProps: RequestParams): Promise<T> {
const { endpoint, realmId, data, method = "GET", queryParams = {}, headers: customHeaders } = reqProps

const isUsingGlobalProxy = (await client.getUserState<boolean>("isUsingGlobalProxy"))[0]?.data ?? false

const dpFetch = await proxyFetch(client)

// Check if the endpoint already contains query parameters
Expand All @@ -36,55 +38,50 @@ export default async function baseRequest<T>(client: IDeskproClient, reqProps: R
requestUrl = `${baseUrl}?${params}`
}

const options: RequestInit = {
method,
body: data,
headers: {
"Authorization": `Bearer ${placeholders.OAUTH2_ACCESS_TOKEN_PATH}`,
"Accept": "application/json",
...customHeaders,
...(data ? { "Content-Type": "application/json" } : {}),
},
};

let res = await dpFetch(requestUrl, options);

if (res.status === 401) {
try {
const tokens = await refreshAccessToken(client);
async function makeRequest(): Promise<Response> {
const options: RequestInit = {
method,
body: data,
headers: {
"Authorization": `Bearer [user[${placeholders.OAUTH2_ACCESS_TOKEN_PATH}]]`,
"Accept": "application/json",
...customHeaders,
...(data ? { "Content-Type": "application/json" } : {}),
},
}

await client.setUserState(placeholders.OAUTH2_ACCESS_TOKEN_PATH, tokens.access_token, {backend: true});
return await dpFetch(requestUrl, options)
}

if (tokens.refresh_token) {
await client.setUserState(placeholders.OAUTH2_REFRESH_TOKEN_PATH, tokens.refresh_token, {backend: true});
};
let response = await makeRequest()

options.headers = {
...options.headers,
'Authorization': `Bearer ${tokens.access_token}`
};
// We cannot refresh the access token if we are using the global proxy
// because the client id and secret key will not be available.
if (response.status === 401 && !isUsingGlobalProxy) {
try {
await refreshAccessToken(client)
response = await makeRequest()

res = await dpFetch(requestUrl, options);
} catch (refreshError) {
throw new QuickBooksError('failed to refresh access token', {statusCode: 401, data: refreshError});
throw new QuickBooksError('failed to refresh access token', { statusCode: 401, data: refreshError });
};
};

if (res.status < 200 || res.status > 399) {
if (response.status < 200 || response.status > 399) {
let errorData: unknown;
const rawText = await res.text()
const rawText = await response.text()

try {
errorData = JSON.parse(rawText)
} catch {
errorData = { message: "Non-JSON error response received", raw: rawText }
}

throw new QuickBooksError("Request failed", { statusCode: res.status, data: errorData })
throw new QuickBooksError("Request failed", { statusCode: response.status, data: errorData })
}

try {
return await res.json() as T
return await response.json() as T
} catch (e) {
throw new QuickBooksError("Failed to parse JSON response", { statusCode: 500, data: e })
}
Expand Down
30 changes: 20 additions & 10 deletions src/api/quickbooks/refreshAccessToken.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { IDeskproClient, OAuth2Result, proxyFetch } from '@deskpro/app-sdk';
import { placeholders } from '@/constants';

export default async function refreshAccessToken(client: IDeskproClient) {
const fetch = await proxyFetch(client);
export default async function refreshAccessToken(client: IDeskproClient): Promise<void> {
const dpFetch = await proxyFetch(client);

const response = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', {
method: 'POST',
const body = `grant_type=refresh_token&refresh_token=[user[${placeholders.OAUTH2_REFRESH_TOKEN_PATH}]]`


const refreshRequestOptions: RequestInit = {
method: "POST",
body: body,
headers: {
'Authorization': "Basic __client_id+':'+client_secret.base64__",
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic __integration_key+':'+secret_key.base64__`,
},
body: `grant_type=refresh_token&refresh_token=[user[${placeholders.OAUTH2_REFRESH_TOKEN_PATH}]]`
});
}

const response = await dpFetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', refreshRequestOptions);

if (!response.ok) {
throw new Error(`QuickBooks token refresh failed with status code: ${response.status}`);
};

return await response.json() as OAuth2Result['data'];
const data = await response.json() as OAuth2Result['data']

await client.setUserState(placeholders.OAUTH2_ACCESS_TOKEN_PATH, data.access_token, { backend: true });

if (data.refresh_token) {
await client.setUserState(placeholders.OAUTH2_REFRESH_TOKEN_PATH, data.refresh_token, { backend: true });
}
};
12 changes: 8 additions & 4 deletions src/pages/loading/LoadingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,25 @@ export default function LoadingPage() {
clearElements();
registerElement('refresh', { type: 'refresh_button' });
}, []);
const isUsingGlobalProxy = context?.settings.use_advanced_connect === false
const isUsingSandbox = context?.settings.use_sandbox === true;
const companyId = context?.settings.company_id

// eslint-disable-next-line @typescript-eslint/no-misused-promises
useInitialisedDeskproAppClient(async client => {
client.setTitle('QuickBooks');

if (!context || !user) {
if (!companyId || !user) {
return;
};

const isUsingSandbox = context.settings.use_sandbox;

await client.setUserState(placeholders.IS_USING_SANDBOX, isUsingSandbox);
await client.setUserState("isUsingGlobalProxy", isUsingGlobalProxy)


try {
const company = await getCompanyInfo(client, context.settings.company_id);
const company = await getCompanyInfo(client, companyId);

if (company) {
setIsAuthenticated(true);
Expand All @@ -46,7 +50,7 @@ export default function LoadingPage() {
} finally {
setIsFetchingAuth(false);
};
}, [context, context?.settings]);
}, [isUsingSandbox, companyId, isUsingGlobalProxy]);


if (!client || !user || isFetchingAuth) {
Expand Down
46 changes: 34 additions & 12 deletions src/pages/login/useLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,24 @@ export default function useLogin(): UseLoginResult {
const { context } = useDeskproLatestAppContext<ContextData, ContextSettings>()

const user = context?.data?.user
const mode = context?.settings.use_advanced_connect === false ? 'global' : 'local';
const isUsingSandbox = context?.settings.use_sandbox === true
const settings = context?.settings

// @todo: Update useInitialisedDeskproAppClient typing in the
// App SDK to to properly handle both async and sync functions

// eslint-disable-next-line @typescript-eslint/no-misused-promises
useInitialisedDeskproAppClient(async client => {
if (!context?.settings || !user) {
if (!settings || !user) {
return;
};

const clientID = context.settings.client_id;
const mode = context.settings.use_advanced_connect ? 'local' : 'global';
const clientId = settings.client_id;

if (mode === 'local' && typeof clientID !== 'string') {
if (mode === 'local' && (typeof clientId !== 'string' || clientId.trim() === "")) {
// Local mode requires a clientId.
setError("No client id was provided while setting up the app, a client id is required when using advanced connect.")
return;
};

Expand All @@ -47,7 +51,7 @@ export default function useLogin(): UseLoginResult {
({ state, callbackUrl }) => {
return `https://appcenter.intuit.com/connect/oauth2?${createSearchParams([
["response_type", "code"],
["client_id", clientID || ''],
["client_id", clientId ?? ''],
["redirect_uri", callbackUrl],
['scope', SCOPE],
["state", state],
Expand All @@ -71,11 +75,11 @@ export default function useLogin(): UseLoginResult {

setAuthUrl(oauth2Response.authorizationUrl)
setOAuth2Context(oauth2Response)
}, [context, setAuthUrl]);
}, [settings, setAuthUrl, mode, user]);


useInitialisedDeskproAppClient((client) => {
if (!user || !oauth2Context) {
if (!user || !oauth2Context || !settings?.company_id) {
return
}

Expand All @@ -90,19 +94,37 @@ export default function useLogin(): UseLoginResult {
}

try {
await getCompanyInfo(client, context.settings.company_id);
await getCompanyInfo(client, settings.company_id);
void navigate('/');
} catch (error) {
if (error instanceof QuickBooksError && isQuickBooksFaultError(error.data)) {
const fault = error.data.Fault ?? error.data.fault;

if (fault?.type === "AuthorizationFault") {
throw new Error("The user logging in isn't a part of the company specified in the app setup. Contact your admin for more information");
switch (fault?.type) {
case "AUTHENTICATION":
throw new Error("An error occurred while authenticating the user.")
case "AuthorizationFault":
throw new Error("The user logging in isn't a part of the company specified in the app setup. Contact your admin for more information.");
case "AuthenticationFault":
if (fault.Error?.[0].Message === "Accessing Wrong Cluster") {
const errorMessage = mode === "global" ?
"Error authenticating user: Sandbox accounts cannot be used with Quick Connect."
:
`Error authenticating user: Ensure the QuickBooks app is setup to use ${isUsingSandbox ? "sandbox" : "production"} accounts.`
throw new Error(errorMessage)
}
break
case "SERVICE":
if (fault.error?.[0].code === "3100") {
throw new Error(`Error authenticating user: Ensure the QuickBooks app is setup to use ${isUsingSandbox ? "sandbox" : "production"} accounts.`)

}
break
}
};

// generic error for all other errors
throw new Error('error authenticating user. Are you using a sandbox company (double-check on QuickBooks, and the settings drawer of the QuickBooks app in Deskpro)?');
throw new Error(`An unexpected error occurred: ${error instanceof Error ? error.message : "Unknown Error"}.`);
};
} catch (error) {
setError(error instanceof Error ? error.message : 'unknown error');
Expand All @@ -115,7 +137,7 @@ export default function useLogin(): UseLoginResult {
if (isPolling) {
void startPolling()
}
}, [isPolling, user, oauth2Context, navigate, context?.settings.company_id]);
}, [isPolling, user, oauth2Context, navigate, settings?.company_id]);

const onSignIn = useCallback(() => {
setIsLoading(true);
Expand Down
2 changes: 1 addition & 1 deletion src/types/quickbooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface QuickBooksErrorObject {
export interface QuickBooksFault {
error?: Pick<QuickBooksErrorObject, "message" | "code" | "detail" | "element" | "statusCode">[]
Error?: Pick<QuickBooksErrorObject, "Message" | "Detail" | "code">[]
type: "AuthorizationFault" | "AUTHENTICATION"
type: "AuthorizationFault" | "AUTHENTICATION" | "SERVICE" | "AuthenticationFault"
}

export interface QuickBooksFaultError {
Expand Down
Loading