diff --git a/jest.config.ts b/jest.config.ts index f4e712f0e..bac4e32fd 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -14,9 +14,10 @@ const config: Config = { }, // Allow transforming specific ESM packages in node_modules that ship untranspiled ESM. // @primer/* libraries rely on lit and @lit-labs/react internally for some components. + // @octokit/* libraries rely on universal-user-agent internally. // We also include GitHub web components that ship ESM-only builds. transformIgnorePatterns: [ - 'node_modules/(?!(?:@primer/react|@primer/primitives|@primer/octicons-react|@lit-labs/react|lit|@github/relative-time-element|@github/tab-container-element)/)', + 'node_modules/(?!(?:@primer/react|@primer/primitives|@primer/octicons-react|@lit-labs/react|lit|@github/relative-time-element|@github/tab-container-element|@octokit/oauth-methods|@octokit/oauth-authorization-url|@octokit/request|@octokit/request-error|@octokit/endpoint|universal-user-agent)/)', ], moduleNameMapper: { // Force CommonJS build for http adapter to be available. diff --git a/package.json b/package.json index d48903906..79f383b4e 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,9 @@ "@electron/notarize": "3.1.1", "@graphql-codegen/cli": "6.1.0", "@graphql-codegen/schema-ast": "5.0.0", + "@octokit/oauth-methods": "6.0.2", "@octokit/openapi-types": "27.0.0", + "@octokit/request": "10.0.7", "@parcel/watcher": "2.5.1", "@primer/css": "22.1.0", "@primer/octicons-react": "19.21.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bafebed9..922600c88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,15 @@ importers: '@graphql-codegen/schema-ast': specifier: 5.0.0 version: 5.0.0(graphql@16.12.0) + '@octokit/oauth-methods': + specifier: 6.0.2 + version: 6.0.2 '@octokit/openapi-types': specifier: 27.0.0 version: 27.0.0 + '@octokit/request': + specifier: 10.0.7 + version: 10.0.7 '@parcel/watcher': specifier: 2.5.1 version: 2.5.1 @@ -1617,9 +1623,32 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@octokit/endpoint@11.0.2': + resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.2': + resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} + engines: {node: '>= 20'} + '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.7': + resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@oddbird/popover-polyfill@0.5.2': resolution: {integrity: sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==} @@ -3288,6 +3317,9 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5649,6 +5681,9 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7878,8 +7913,38 @@ snapshots: dependencies: semver: 7.7.3 + '@octokit/endpoint@11.0.2': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.2': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + '@octokit/openapi-types@27.0.0': {} + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.7': + dependencies: + '@octokit/endpoint': 11.0.2 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@oddbird/popover-polyfill@0.5.2': {} '@parcel/watcher-android-arm64@2.5.1': @@ -9718,6 +9783,8 @@ snapshots: extsprintf@1.4.1: optional: true + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -12236,6 +12303,8 @@ snapshots: dependencies: imurmurhash: 0.1.4 + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@2.0.1: {} diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 340f6c3b4..21844adcf 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,4 +1,5 @@ import type { ClientID, ClientSecret, Hostname, Link } from './types'; +import type { LoginOAuthAppOptions } from './utils/auth/types'; export const Constants = { STORAGE_KEY: 'gitify-storage', @@ -13,8 +14,9 @@ export const Constants = { hostname: 'github.com' as Hostname, clientId: process.env.OAUTH_CLIENT_ID as ClientID, clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, - }, + } satisfies LoginOAuthAppOptions, + GITHUB_BASE_URL: 'https://github.com', GITHUB_API_BASE_URL: 'https://api.github.com', GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql', diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 8035535e4..bd7b4ac17 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -37,10 +37,10 @@ import type { } from '../utils/auth/types'; import { addAccount, - authGitHub, + exchangeAuthCodeForAccessToken, getAccountUUID, - getToken, hasAccounts, + performGitHubOAuth, refreshAccount, removeAccount, } from '../utils/auth/utils'; @@ -405,9 +405,15 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { return hasAccounts(auth); }, [auth]); + /** + * Login with GitHub App. + * + * Note: although we call this "Login with GitHub App", this function actually + * authenticates via a predefined "Gitify" GitHub OAuth App. + */ const loginWithGitHubApp = useCallback(async () => { - const { authCode } = await authGitHub(); - const { token } = await getToken(authCode); + const { authCode } = await performGitHubOAuth(); + const token = await exchangeAuthCodeForAccessToken(authCode); const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname; const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); @@ -415,18 +421,29 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { persistAuth(updatedAuth); }, [auth, persistAuth]); + /** + * Login with custom GitHub OAuth App. + */ const loginWithOAuthApp = useCallback( async (data: LoginOAuthAppOptions) => { - const { authOptions, authCode } = await authGitHub(data); - const { token, hostname } = await getToken(authCode, authOptions); + const { authOptions, authCode } = await performGitHubOAuth(data); + const token = await exchangeAuthCodeForAccessToken(authCode, authOptions); - const updatedAuth = await addAccount(auth, 'OAuth App', token, hostname); + const updatedAuth = await addAccount( + auth, + 'OAuth App', + token, + authOptions.hostname, + ); persistAuth(updatedAuth); }, [auth, persistAuth], ); + /** + * Login with Personal Access Token (PAT). + */ const loginWithPersonalAccessToken = useCallback( async ({ token, hostname }: LoginPersonalAccessTokenOptions) => { const encryptedToken = (await encryptValue(token)) as Token; diff --git a/src/renderer/utils/api/graphql/generated/gql.ts b/src/renderer/utils/api/graphql/generated/gql.ts index e72c24c08..aa6d78043 100644 --- a/src/renderer/utils/api/graphql/generated/gql.ts +++ b/src/renderer/utils/api/graphql/generated/gql.ts @@ -20,7 +20,7 @@ type Documents = { "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": typeof types.FetchIssueByNumberDocument, "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}": typeof types.FetchMergedDetailsTemplateDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": typeof types.FetchPullRequestByNumberDocument, - "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": typeof types.FetchAuthenticatedUserDetailsDocument, + "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatar: avatarUrl\n }\n}": typeof types.FetchAuthenticatedUserDetailsDocument, }; const documents: Documents = { "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl: avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": types.AuthorFieldsFragmentDoc, @@ -28,7 +28,7 @@ const documents: Documents = { "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": types.FetchIssueByNumberDocument, "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}": types.FetchMergedDetailsTemplateDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": types.FetchPullRequestByNumberDocument, - "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": types.FetchAuthenticatedUserDetailsDocument, + "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatar: avatarUrl\n }\n}": types.FetchAuthenticatedUserDetailsDocument, }; /** @@ -54,7 +54,7 @@ export function graphql(source: "query FetchPullRequestByNumber($owner: String!, /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}"): typeof import('./graphql').FetchAuthenticatedUserDetailsDocument; +export function graphql(source: "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatar: avatarUrl\n }\n}"): typeof import('./graphql').FetchAuthenticatedUserDetailsDocument; export function graphql(source: string) { diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts index fda34b59c..24969e990 100644 --- a/src/renderer/utils/api/graphql/generated/graphql.ts +++ b/src/renderer/utils/api/graphql/generated/graphql.ts @@ -4662,60 +4662,6 @@ export type CreateSponsorshipsPayload = { sponsorables?: Maybe>; }; -/** Autogenerated input type of CreateTeamDiscussion */ -export type CreateTeamDiscussionInput = { - /** - * The content of the discussion. This field is required. - * - * **Upcoming Change on 2024-07-01 UTC** - * **Description:** `body` will be removed. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. - * **Reason:** The Team Discussions feature is deprecated in favor of Organization Discussions. - * - */ - body?: InputMaybe; - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: InputMaybe; - /** - * If true, restricts the visibility of this discussion to team members and organization owners. If false or not specified, allows any organization member to view this discussion. - * - * **Upcoming Change on 2024-07-01 UTC** - * **Description:** `private` will be removed. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. - * **Reason:** The Team Discussions feature is deprecated in favor of Organization Discussions. - * - */ - private?: InputMaybe; - /** - * The ID of the team to which the discussion belongs. This field is required. - * - * **Upcoming Change on 2024-07-01 UTC** - * **Description:** `teamId` will be removed. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. - * **Reason:** The Team Discussions feature is deprecated in favor of Organization Discussions. - * - */ - teamId?: InputMaybe; - /** - * The title of the discussion. This field is required. - * - * **Upcoming Change on 2024-07-01 UTC** - * **Description:** `title` will be removed. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. - * **Reason:** The Team Discussions feature is deprecated in favor of Organization Discussions. - * - */ - title?: InputMaybe; -}; - -/** Autogenerated return type of CreateTeamDiscussion. */ -export type CreateTeamDiscussionPayload = { - __typename?: 'CreateTeamDiscussionPayload'; - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: Maybe; - /** - * The new discussion. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - teamDiscussion?: Maybe; -}; - /** Autogenerated input type of CreateUserList */ export type CreateUserListInput = { /** A unique identifier for the client performing the mutation. */ @@ -5535,21 +5481,6 @@ export type DeleteRepositoryRulesetPayload = { clientMutationId?: Maybe; }; -/** Autogenerated input type of DeleteTeamDiscussion */ -export type DeleteTeamDiscussionInput = { - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: InputMaybe; - /** The discussion ID to delete. */ - id: Scalars['ID']['input']; -}; - -/** Autogenerated return type of DeleteTeamDiscussion. */ -export type DeleteTeamDiscussionPayload = { - __typename?: 'DeleteTeamDiscussionPayload'; - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: Maybe; -}; - /** Autogenerated input type of DeleteUserList */ export type DeleteUserListInput = { /** A unique identifier for the client performing the mutation. */ @@ -7575,10 +7506,11 @@ export type EnterpriseOwnerInfo = { samlIdentityProviderSettingOrganizations: OrganizationConnection; /** A list of members with a support entitlement. */ supportEntitlements: EnterpriseMemberConnection; - /** The setting value for whether team discussions are enabled for organizations in this enterprise. */ + /** + * The setting value for whether team discussions are enabled for organizations in this enterprise. + * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. + */ teamDiscussionsSetting: EnterpriseEnabledDisabledSettingValue; - /** A list of enterprise organizations configured with the provided team discussions setting value. */ - teamDiscussionsSettingOrganizations: OrganizationConnection; /** The setting value for what methods of two-factor authentication the enterprise prevents its users from having. */ twoFactorDisallowedMethodsSetting: EnterpriseDisallowedMethodsSettingValue; /** The setting value for whether the enterprise requires two-factor authentication for its organizations and users. */ @@ -7870,17 +7802,6 @@ export type EnterpriseOwnerInfoSupportEntitlementsArgs = { }; -/** Enterprise information visible to enterprise owners or enterprise owners' personal access tokens (classic) with read:enterprise or admin:enterprise scope. */ -export type EnterpriseOwnerInfoTeamDiscussionsSettingOrganizationsArgs = { - after?: InputMaybe; - before?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - orderBy?: InputMaybe; - value: Scalars['Boolean']['input']; -}; - - /** Enterprise information visible to enterprise owners or enterprise owners' personal access tokens (classic) with read:enterprise or admin:enterprise scope. */ export type EnterpriseOwnerInfoTwoFactorRequiredSettingOrganizationsArgs = { after?: InputMaybe; @@ -12313,8 +12234,6 @@ export type Mutation = { createSponsorship?: Maybe; /** Make many sponsorships for different sponsorable users or organizations at once. Can only sponsor those who have a public GitHub Sponsors profile. */ createSponsorships?: Maybe; - /** Creates a new team discussion. */ - createTeamDiscussion?: Maybe; /** Creates a new user list. */ createUserList?: Maybe; /** Rejects a suggested topic for the repository. */ @@ -12378,8 +12297,6 @@ export type Mutation = { deleteRepositoryCustomProperty?: Maybe; /** Delete a repository ruleset */ deleteRepositoryRuleset?: Maybe; - /** Deletes a team discussion. */ - deleteTeamDiscussion?: Maybe; /** Deletes a user list. */ deleteUserList?: Maybe; /** Deletes a verifiable domain. */ @@ -12609,8 +12526,6 @@ export type Mutation = { updateEnterpriseProfile?: Maybe; /** Sets whether repository projects are enabled for a enterprise. */ updateEnterpriseRepositoryProjectsSetting?: Maybe; - /** Sets whether team discussions are enabled for an enterprise. */ - updateEnterpriseTeamDiscussionsSetting?: Maybe; /** Sets the two-factor authentication methods that users of an enterprise may not use. */ updateEnterpriseTwoFactorAuthenticationDisallowedMethodsSetting?: Maybe; /** Sets whether two factor authentication is required for all users in an enterprise. */ @@ -12714,8 +12629,6 @@ export type Mutation = { updateSponsorshipPreferences?: Maybe; /** Updates the state for subscribable subjects. */ updateSubscription?: Maybe; - /** Updates a team discussion. */ - updateTeamDiscussion?: Maybe; /** Updates team review assignment. */ updateTeamReviewAssignment?: Maybe; /** Update team repository. */ @@ -13181,12 +13094,6 @@ export type MutationCreateSponsorshipsArgs = { }; -/** The root query for implementing GraphQL mutations. */ -export type MutationCreateTeamDiscussionArgs = { - input: CreateTeamDiscussionInput; -}; - - /** The root query for implementing GraphQL mutations. */ export type MutationCreateUserListArgs = { input: CreateUserListInput; @@ -13349,12 +13256,6 @@ export type MutationDeleteRepositoryRulesetArgs = { }; -/** The root query for implementing GraphQL mutations. */ -export type MutationDeleteTeamDiscussionArgs = { - input: DeleteTeamDiscussionInput; -}; - - /** The root query for implementing GraphQL mutations. */ export type MutationDeleteUserListArgs = { input: DeleteUserListInput; @@ -13997,12 +13898,6 @@ export type MutationUpdateEnterpriseRepositoryProjectsSettingArgs = { }; -/** The root query for implementing GraphQL mutations. */ -export type MutationUpdateEnterpriseTeamDiscussionsSettingArgs = { - input: UpdateEnterpriseTeamDiscussionsSettingInput; -}; - - /** The root query for implementing GraphQL mutations. */ export type MutationUpdateEnterpriseTwoFactorAuthenticationDisallowedMethodsSettingArgs = { input: UpdateEnterpriseTwoFactorAuthenticationDisallowedMethodsSettingInput; @@ -14225,12 +14120,6 @@ export type MutationUpdateSubscriptionArgs = { }; -/** The root query for implementing GraphQL mutations. */ -export type MutationUpdateTeamDiscussionArgs = { - input: UpdateTeamDiscussionInput; -}; - - /** The root query for implementing GraphQL mutations. */ export type MutationUpdateTeamReviewAssignmentArgs = { input: UpdateTeamReviewAssignmentInput; @@ -31254,14 +31143,6 @@ export type Team = MemberStatusable & Node & Subscribable & { databaseId?: Maybe; /** The description of the team. */ description?: Maybe; - /** Find a team discussion by its number. */ - discussion?: Maybe; - /** A list of team discussions. */ - discussions: TeamDiscussionConnection; - /** The HTTP path for team discussions */ - discussionsResourcePath: Scalars['URI']['output']; - /** The HTTP URL for team discussions */ - discussionsUrl: Scalars['URI']['output']; /** The HTTP path for editing this team */ editTeamResourcePath: Scalars['URI']['output']; /** The HTTP URL for editing this team */ @@ -31358,23 +31239,6 @@ export type TeamChildTeamsArgs = { }; -/** A team of users in an organization. */ -export type TeamDiscussionArgs = { - number: Scalars['Int']['input']; -}; - - -/** A team of users in an organization. */ -export type TeamDiscussionsArgs = { - after?: InputMaybe; - before?: InputMaybe; - first?: InputMaybe; - isPinned?: InputMaybe; - last?: InputMaybe; - orderBy?: InputMaybe; -}; - - /** A team of users in an organization. */ export type TeamInvitationsArgs = { after?: InputMaybe; @@ -31824,172 +31688,6 @@ export type TeamConnection = { totalCount: Scalars['Int']['output']; }; -/** A team discussion. */ -export type TeamDiscussion = Comment & Deletable & Node & Reactable & Subscribable & UniformResourceLocatable & Updatable & UpdatableComment & { - __typename?: 'TeamDiscussion'; - /** The actor who authored the comment. */ - author?: Maybe; - /** - * Author's association with the discussion's team. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - authorAssociation: CommentAuthorAssociation; - /** The body as Markdown. */ - body: Scalars['String']['output']; - /** The body rendered to HTML. */ - bodyHTML: Scalars['HTML']['output']; - /** The body rendered to text. */ - bodyText: Scalars['String']['output']; - /** - * Identifies the discussion body hash. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - bodyVersion: Scalars['String']['output']; - /** - * The HTTP path for discussion comments - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - commentsResourcePath: Scalars['URI']['output']; - /** - * The HTTP URL for discussion comments - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - commentsUrl: Scalars['URI']['output']; - /** Identifies the date and time when the object was created. */ - createdAt: Scalars['DateTime']['output']; - /** Check if this comment was created via an email reply. */ - createdViaEmail: Scalars['Boolean']['output']; - /** Identifies the primary key from the database. */ - databaseId?: Maybe; - /** The actor who edited the comment. */ - editor?: Maybe; - /** The Node ID of the TeamDiscussion object */ - id: Scalars['ID']['output']; - /** Check if this comment was edited and includes an edit with the creation data */ - includesCreatedEdit: Scalars['Boolean']['output']; - /** - * Whether or not the discussion is pinned. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - isPinned: Scalars['Boolean']['output']; - /** - * Whether or not the discussion is only visible to team members and organization owners. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - isPrivate: Scalars['Boolean']['output']; - /** The moment the editor made the last edit */ - lastEditedAt?: Maybe; - /** - * Identifies the discussion within its team. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - number: Scalars['Int']['output']; - /** Identifies when the comment was published at. */ - publishedAt?: Maybe; - /** A list of reactions grouped by content left on the subject. */ - reactionGroups?: Maybe>; - /** A list of Reactions left on the Issue. */ - reactions: ReactionConnection; - /** - * The HTTP path for this discussion - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - resourcePath: Scalars['URI']['output']; - /** - * The team that defines the context of this discussion. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - team: Team; - /** - * The title of the discussion - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - title: Scalars['String']['output']; - /** Identifies the date and time when the object was last updated. */ - updatedAt: Scalars['DateTime']['output']; - /** - * The HTTP URL for this discussion - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - url: Scalars['URI']['output']; - /** A list of edits to this content. */ - userContentEdits?: Maybe; - /** Check if the current viewer can delete this object. */ - viewerCanDelete: Scalars['Boolean']['output']; - /** - * Whether or not the current viewer can pin this discussion. - * @deprecated The Team Discussions feature is deprecated in favor of Organization Discussions. Follow the guide at https://github.blog/changelog/2023-02-08-sunset-notice-team-discussions/ to find a suitable replacement. Removal on 2024-07-01 UTC. - */ - viewerCanPin: Scalars['Boolean']['output']; - /** Can user react to this subject */ - viewerCanReact: Scalars['Boolean']['output']; - /** Check if the viewer is able to change their subscription status for the repository. */ - viewerCanSubscribe: Scalars['Boolean']['output']; - /** Check if the current viewer can update this object. */ - viewerCanUpdate: Scalars['Boolean']['output']; - /** Reasons why the current viewer can not update this comment. */ - viewerCannotUpdateReasons: Array; - /** Did the viewer author this comment. */ - viewerDidAuthor: Scalars['Boolean']['output']; - /** Identifies if the viewer is watching, not watching, or ignoring the subscribable entity. */ - viewerSubscription?: Maybe; -}; - - -/** A team discussion. */ -export type TeamDiscussionReactionsArgs = { - after?: InputMaybe; - before?: InputMaybe; - content?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - orderBy?: InputMaybe; -}; - - -/** A team discussion. */ -export type TeamDiscussionUserContentEditsArgs = { - after?: InputMaybe; - before?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; -}; - -/** The connection type for TeamDiscussion. */ -export type TeamDiscussionConnection = { - __typename?: 'TeamDiscussionConnection'; - /** A list of edges. */ - edges?: Maybe>>; - /** A list of nodes. */ - nodes?: Maybe>>; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** Identifies the total count of items in the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** An edge in a connection. */ -export type TeamDiscussionEdge = { - __typename?: 'TeamDiscussionEdge'; - /** A cursor for use in pagination. */ - cursor: Scalars['String']['output']; - /** The item at the end of the edge. */ - node?: Maybe; -}; - -/** Ways in which team discussion connections can be ordered. */ -export type TeamDiscussionOrder = { - /** The direction in which to order nodes. */ - direction: OrderDirection; - /** The field by which to order nodes. */ - field: TeamDiscussionOrderField; -}; - -/** Properties by which team discussion connections can be ordered. */ -export type TeamDiscussionOrderField = - /** Allows chronological ordering of team discussions. */ - | 'CREATED_AT'; - /** An edge in a connection. */ export type TeamEdge = { __typename?: 'TeamEdge'; @@ -33543,27 +33241,6 @@ export type UpdateEnterpriseRepositoryProjectsSettingPayload = { message?: Maybe; }; -/** Autogenerated input type of UpdateEnterpriseTeamDiscussionsSetting */ -export type UpdateEnterpriseTeamDiscussionsSettingInput = { - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: InputMaybe; - /** The ID of the enterprise on which to set the team discussions setting. */ - enterpriseId: Scalars['ID']['input']; - /** The value for the team discussions setting on the enterprise. */ - settingValue: EnterpriseEnabledDisabledSettingValue; -}; - -/** Autogenerated return type of UpdateEnterpriseTeamDiscussionsSetting. */ -export type UpdateEnterpriseTeamDiscussionsSettingPayload = { - __typename?: 'UpdateEnterpriseTeamDiscussionsSettingPayload'; - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: Maybe; - /** The enterprise with the updated team discussions setting. */ - enterprise?: Maybe; - /** A message confirming the result of updating the team discussions setting. */ - message?: Maybe; -}; - /** Autogenerated input type of UpdateEnterpriseTwoFactorAuthenticationDisallowedMethodsSetting */ export type UpdateEnterpriseTwoFactorAuthenticationDisallowedMethodsSettingInput = { /** A unique identifier for the client performing the mutation. */ @@ -34446,31 +34123,6 @@ export type UpdateSubscriptionPayload = { subscribable?: Maybe; }; -/** Autogenerated input type of UpdateTeamDiscussion */ -export type UpdateTeamDiscussionInput = { - /** The updated text of the discussion. */ - body?: InputMaybe; - /** The current version of the body content. If provided, this update operation will be rejected if the given version does not match the latest version on the server. */ - bodyVersion?: InputMaybe; - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: InputMaybe; - /** The Node ID of the discussion to modify. */ - id: Scalars['ID']['input']; - /** If provided, sets the pinned state of the updated discussion. */ - pinned?: InputMaybe; - /** The updated title of the discussion. */ - title?: InputMaybe; -}; - -/** Autogenerated return type of UpdateTeamDiscussion. */ -export type UpdateTeamDiscussionPayload = { - __typename?: 'UpdateTeamDiscussionPayload'; - /** A unique identifier for the client performing the mutation. */ - clientMutationId?: Maybe; - /** The updated discussion. */ - teamDiscussion?: Maybe; -}; - /** Autogenerated input type of UpdateTeamReviewAssignment */ export type UpdateTeamReviewAssignmentInput = { /** The algorithm to use for review assignment */ @@ -35892,7 +35544,7 @@ export type WorkflowsParametersInput = { workflows: Array; }; -export type _Entity = Issue; +export type _Entity = Issue | Repository; type AuthorFields_Bot_Fragment = { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' }; @@ -36203,7 +35855,7 @@ export type PullRequestReviewFieldsFragment = { __typename?: 'PullRequestReview' export type FetchAuthenticatedUserDetailsQueryVariables = Exact<{ [key: string]: never; }>; -export type FetchAuthenticatedUserDetailsQuery = { __typename?: 'Query', viewer: { __typename?: 'User', id: string, name?: string | null, login: string, avatarUrl: any } }; +export type FetchAuthenticatedUserDetailsQuery = { __typename?: 'Query', viewer: { __typename?: 'User', id: string, name?: string | null, login: string, avatar: any } }; export class TypedDocumentString extends String @@ -36876,7 +36528,7 @@ export const FetchAuthenticatedUserDetailsDocument = new TypedDocumentString(` id name login - avatarUrl + avatar: avatarUrl } } `) as unknown as TypedDocumentString; \ No newline at end of file diff --git a/src/renderer/utils/api/graphql/user.graphql b/src/renderer/utils/api/graphql/user.graphql index a69b914f6..9f0d86b71 100644 --- a/src/renderer/utils/api/graphql/user.graphql +++ b/src/renderer/utils/api/graphql/user.graphql @@ -3,6 +3,6 @@ query FetchAuthenticatedUserDetails { id name login - avatarUrl + avatar: avatarUrl } } diff --git a/src/renderer/utils/api/request.test.ts b/src/renderer/utils/api/request.test.ts index dce087d4b..b7f7ea2d9 100644 --- a/src/renderer/utils/api/request.test.ts +++ b/src/renderer/utils/api/request.test.ts @@ -9,7 +9,6 @@ import { } from './__mocks__/request-mocks'; import { FetchAuthenticatedUserDetailsDocument } from './graphql/generated/graphql'; import { - apiRequest, apiRequestAuth, getHeaders, performGraphQLRequest, @@ -27,33 +26,6 @@ describe('renderer/utils/api/request.ts', () => { jest.clearAllMocks(); }); - describe('apiRequest', () => { - it('should make a request with the correct parameters', async () => { - const data = { key: 'value' }; - - await apiRequest(url, method, data); - - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockNoAuthHeaders, - }); - }); - - it('should make a request with the correct parameters and default data', async () => { - const data = {}; - await apiRequest(url, method); - - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockNoAuthHeaders, - }); - }); - }); - describe('apiRequestAuth', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/src/renderer/utils/api/request.ts b/src/renderer/utils/api/request.ts index 201f4fe2a..7f81cdb0a 100644 --- a/src/renderer/utils/api/request.ts +++ b/src/renderer/utils/api/request.ts @@ -18,24 +18,6 @@ export type ExecutionResultWithHeaders = ExecutionResult & { headers: Record; }; -/** - * Perform an unauthenticated API request - * - * @param url - * @param method - * @param data - * @returns - */ -export async function apiRequest( - url: Link, - method: Method, - data = {}, -): Promise { - const headers = await getHeaders(url); - - return axios({ method, url, data, headers }); -} - /** * Perform an authenticated API request * diff --git a/src/renderer/utils/api/utils.test.ts b/src/renderer/utils/api/utils.test.ts index 9435c95cc..4c86631e5 100644 --- a/src/renderer/utils/api/utils.test.ts +++ b/src/renderer/utils/api/utils.test.ts @@ -3,11 +3,24 @@ import type { AxiosResponse } from 'axios'; import type { Hostname } from '../../types'; import { getGitHubAPIBaseUrl, + getGitHubAuthBaseUrl, getGitHubGraphQLUrl, getNextURLFromLinkHeader, } from './utils'; describe('renderer/utils/api/utils.ts', () => { + describe('getGitHubAuthBaseUrl', () => { + it('should generate a GitHub Auth url - non enterprise', () => { + const result = getGitHubAuthBaseUrl('github.com' as Hostname); + expect(result.toString()).toBe('https://github.com/'); + }); + + it('should generate a GitHub Auth url - enterprise', () => { + const result = getGitHubAuthBaseUrl('github.gitify.io' as Hostname); + expect(result.toString()).toBe('https://github.gitify.io/api/v3/'); + }); + }); + describe('getGitHubAPIBaseUrl', () => { it('should generate a GitHub API url - non enterprise', () => { const result = getGitHubAPIBaseUrl('github.com' as Hostname); diff --git a/src/renderer/utils/api/utils.ts b/src/renderer/utils/api/utils.ts index cf47d1c94..67a7b50ac 100644 --- a/src/renderer/utils/api/utils.ts +++ b/src/renderer/utils/api/utils.ts @@ -14,6 +14,16 @@ export function getGitHubAPIBaseUrl(hostname: Hostname): URL { return url; } +export function getGitHubAuthBaseUrl(hostname: Hostname): URL { + const url = new URL(Constants.GITHUB_BASE_URL); + + if (isEnterpriseServerHost(hostname)) { + url.hostname = hostname; + url.pathname = '/api/v3/'; + } + return url; +} + export function getGitHubGraphQLUrl(hostname: Hostname): URL { const url = new URL(Constants.GITHUB_API_GRAPHQL_URL); diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index c50b68621..684f73904 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -26,8 +26,3 @@ export interface AuthResponse { authCode: AuthCode; authOptions: LoginOAuthAppOptions; } - -export interface AuthTokenResponse { - hostname: Hostname; - token: Token; -} diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index e008b078d..71d25434f 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -1,4 +1,3 @@ -import type { AxiosResponse } from 'axios'; import axios from 'axios'; import { mockGitHubCloudAccount } from '../../__mocks__/account-mocks'; @@ -16,14 +15,21 @@ import type { } from '../../types'; import * as comms from '../../utils/comms'; import * as apiClient from '../api/client'; -import type { FetchAuthenticatedUserDetailsQuery } from '../api/graphql/generated/graphql'; -import * as apiRequests from '../api/request'; import * as logger from '../logger'; import type { AuthMethod } from './types'; import * as authUtils from './utils'; import { getNewOAuthAppURL, getNewTokenURL } from './utils'; -type UserDetailsResponse = FetchAuthenticatedUserDetailsQuery['viewer']; +jest.mock('@octokit/oauth-methods', () => ({ + ...jest.requireActual('@octokit/oauth-methods'), + exchangeWebFlowCode: jest.fn(), +})); + +import { exchangeWebFlowCode } from '@octokit/oauth-methods'; + +const exchangeWebFlowCodeMock = exchangeWebFlowCode as jest.MockedFunction< + typeof exchangeWebFlowCode +>; describe('renderer/utils/auth/utils.ts', () => { describe('authGitHub', () => { @@ -36,18 +42,20 @@ describe('renderer/utils/auth/utils.ts', () => { jest.clearAllMocks(); }); - it('should call authGitHub - success auth flow', async () => { + it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://auth?code=123-456'); }); - const res = await authUtils.authGitHub(); + const res = await authUtils.performGitHubOAuth(); expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + expect.stringContaining( + 'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + ), ); expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); @@ -59,14 +67,14 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call authGitHub - success oauth flow', async () => { + it('should call performGitHubOAuth using custom oauth app - success oauth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://oauth?code=123-456'); }); - const res = await authUtils.authGitHub({ + const res = await authUtils.performGitHubOAuth({ clientId: 'BYO_CLIENT_ID' as ClientID, clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret, hostname: 'my.git.com' as Hostname, @@ -74,7 +82,9 @@ describe('renderer/utils/auth/utils.ts', () => { expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://my.git.com/login/oauth/authorize?client_id=BYO_CLIENT_ID&scope=read%3Auser%2Cnotifications%2Crepo', + expect.stringContaining( + 'https://my.git.com/login/oauth/authorize?allow_signup=false&client_id=BYO_CLIENT_ID&scope=read%3Auser%2Cnotifications%2Crepo', + ), ); expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); @@ -86,7 +96,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call authGitHub - failure', async () => { + it('should call performGitHubOAuth - failure', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { @@ -95,7 +105,9 @@ describe('renderer/utils/auth/utils.ts', () => { ); }); - await expect(async () => await authUtils.authGitHub()).rejects.toEqual( + await expect( + async () => await authUtils.performGitHubOAuth(), + ).rejects.toEqual( new Error( "Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: The redirect_uri is missing or invalid. Docs: https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors", ), @@ -103,7 +115,9 @@ describe('renderer/utils/auth/utils.ts', () => { expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + expect.stringContaining( + 'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + ), ); expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); @@ -113,30 +127,29 @@ describe('renderer/utils/auth/utils.ts', () => { }); }); - describe('getToken', () => { + describe('exchangeAuthCodeForAccessToken', () => { const authCode = '123-456' as AuthCode; - const apiRequestSpy = jest.spyOn(apiRequests, 'apiRequest'); - it('should get a token', async () => { - apiRequestSpy.mockResolvedValueOnce( - Promise.resolve({ - data: { access_token: 'this-is-a-token' }, - } as AxiosResponse), - ); - - const res = await authUtils.getToken(authCode); - - expect(apiRequests.apiRequest).toHaveBeenCalledWith( - 'https://github.com/login/oauth/access_token', - 'POST', - { - client_id: 'FAKE_CLIENT_ID_123', - client_secret: 'FAKE_CLIENT_SECRET_123', - code: '123-456', + it('should exchange auth code for access token', async () => { + exchangeWebFlowCodeMock.mockResolvedValueOnce({ + authentication: { + token: 'this-is-a-token', }, + } as any); + + const res = await authUtils.exchangeAuthCodeForAccessToken( + authCode, + Constants.DEFAULT_AUTH_OPTIONS, ); - expect(res.token).toBe('this-is-a-token'); - expect(res.hostname).toBe('github.com' as Hostname); + + expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + clientSecret: 'FAKE_CLIENT_SECRET_123', + code: '123-456', + request: expect.any(Function), + }); + expect(res).toBe('this-is-a-token'); }); }); @@ -161,10 +174,7 @@ describe('renderer/utils/auth/utils.ts', () => { beforeEach(() => { fetchAuthenticatedUserDetailsSpy.mockResolvedValue({ data: { - viewer: { - ...mockGitifyUser, - avatarUrl: mockGitifyUser.avatar, - } as UserDetailsResponse, + viewer: mockGitifyUser, }, headers: { 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED.join(', '), @@ -219,10 +229,7 @@ describe('renderer/utils/auth/utils.ts', () => { beforeEach(() => { fetchAuthenticatedUserDetailsSpy.mockResolvedValue({ data: { - viewer: { - ...mockGitifyUser, - avatarUrl: mockGitifyUser.avatar, - } as UserDetailsResponse, + viewer: mockGitifyUser, }, headers: { 'x-github-enterprise-version': '3.0.0', diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 3e894568f..325ae0911 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -1,3 +1,8 @@ +import { + exchangeWebFlowCode, + getWebFlowAuthorizationUrl, +} from '@octokit/oauth-methods'; +import { request } from '@octokit/request'; import { format } from 'date-fns'; import semver from 'semver'; @@ -14,25 +19,27 @@ import type { Token, } from '../../types'; import { fetchAuthenticatedUserDetails } from '../api/client'; -import { apiRequest } from '../api/request'; +import { getGitHubAuthBaseUrl } from '../api/utils'; import { encryptValue, openExternalLink } from '../comms'; import { getPlatformFromHostname } from '../helpers'; import { rendererLogError, rendererLogInfo, rendererLogWarn } from '../logger'; -import type { AuthMethod, AuthResponse, AuthTokenResponse } from './types'; +import type { AuthMethod, AuthResponse, LoginOAuthAppOptions } from './types'; -export function authGitHub( - authOptions = Constants.DEFAULT_AUTH_OPTIONS, +export function performGitHubOAuth( + authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, ): Promise { return new Promise((resolve, reject) => { - const authUrl = new URL(`https://${authOptions.hostname}`); - authUrl.pathname = '/login/oauth/authorize'; - authUrl.searchParams.append('client_id', authOptions.clientId); - authUrl.searchParams.append( - 'scope', - Constants.OAUTH_SCOPES.RECOMMENDED.toString(), - ); + const { url } = getWebFlowAuthorizationUrl({ + clientType: 'oauth-app', + clientId: authOptions.clientId, + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + allowSignup: false, + request: request.defaults({ + baseUrl: `https://${authOptions.hostname}`, + }), + }); - openExternalLink(authUrl.toString() as Link); + openExternalLink(url as Link); const handleCallback = (callbackUrl: string) => { const url = new URL(callbackUrl); @@ -71,23 +78,21 @@ export function authGitHub( }); } -export async function getToken( +export async function exchangeAuthCodeForAccessToken( authCode: AuthCode, - authOptions = Constants.DEFAULT_AUTH_OPTIONS, -): Promise { - const url = - `https://${authOptions.hostname}/login/oauth/access_token` as Link; - const data = { - client_id: authOptions.clientId, - client_secret: authOptions.clientSecret, + authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, +): Promise { + const { authentication } = await exchangeWebFlowCode({ + clientType: 'oauth-app', + clientId: authOptions.clientId, + clientSecret: authOptions.clientSecret, code: authCode, - }; + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl(authOptions.hostname).toString(), + }), + }); - const response = await apiRequest(url, 'POST', data); - return { - hostname: authOptions.hostname, - token: response.data.access_token, - }; + return authentication.token as Token; } export async function addAccount( @@ -150,7 +155,7 @@ export async function refreshAccount(account: Account): Promise { id: user.id, login: user.login, name: user.name, - avatar: user.avatarUrl, + avatar: user.avatar, }; account.version = extractHostVersion(