diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index a40a0cc4c..d27107f3f 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -3,6 +3,7 @@ import { Constants } from '../constants'; import type { Account, AccountNotifications, + Forge, GitifyError, Hostname, Token, @@ -11,7 +12,10 @@ import type { import { getRecommendedScopeNames } from '../utils/auth/scopes'; import { mockGitifyUser } from './user-mocks'; +const defaultForge: Forge = 'github'; + export const mockGitHubAppAccount: Account = { + forge: defaultForge, platform: 'GitHub Cloud', method: 'GitHub App', token: 'token-987654321' as Token, @@ -30,6 +34,7 @@ export const mockPersonalAccessTokenAccount: Account = { }; export const mockOAuthAccount: Account = { + forge: defaultForge, platform: 'GitHub Enterprise Server', method: 'OAuth App', token: 'token-1234568790' as Token, @@ -39,6 +44,7 @@ export const mockOAuthAccount: Account = { }; export const mockGitHubCloudAccount: Account = { + forge: defaultForge, platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, @@ -48,6 +54,7 @@ export const mockGitHubCloudAccount: Account = { }; export const mockGitHubEnterpriseServerAccount: Account = { + forge: defaultForge, platform: 'GitHub Enterprise Server', method: 'Personal Access Token', token: 'token-1234568790' as Token, @@ -55,6 +62,15 @@ export const mockGitHubEnterpriseServerAccount: Account = { user: mockGitifyUser, }; +export const mockGiteaAccount: Account = { + forge: 'gitea', + platform: 'Gitea', + method: 'Personal Access Token', + token: 'token-gitea' as Token, + hostname: 'gitea.example.com' as Hostname, + user: mockGitifyUser, +}; + export function mockAccountWithError(error: GitifyError): AccountNotifications { return { account: mockGitHubCloudAccount, diff --git a/src/renderer/__mocks__/notifications-mocks.ts b/src/renderer/__mocks__/notifications-mocks.ts index abe57cb6a..c5fb7983c 100644 --- a/src/renderer/__mocks__/notifications-mocks.ts +++ b/src/renderer/__mocks__/notifications-mocks.ts @@ -16,6 +16,7 @@ import { } from '../types'; import { + mockGiteaAccount, mockGitHubAppAccount, mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, @@ -218,6 +219,12 @@ export const mockGithubEnterpriseGitifyNotifications: GitifyNotification[] = [ export const mockGitifyNotification: GitifyNotification = mockGitHubCloudGitifyNotifications[0]; +/** Same shape as cloud notification, but bound to a Gitea account (for forge-specific tests). */ +export const mockGiteaGitifyNotification: GitifyNotification = { + ...mockGitifyNotification, + account: mockGiteaAccount, +}; + export const mockMultipleAccountNotifications: AccountNotifications[] = [ { account: mockGitHubCloudAccount, diff --git a/src/renderer/components/notifications/NotificationRow.test.tsx b/src/renderer/components/notifications/NotificationRow.test.tsx index 0e81a211b..b8e546976 100644 --- a/src/renderer/components/notifications/NotificationRow.test.tsx +++ b/src/renderer/components/notifications/NotificationRow.test.tsx @@ -2,7 +2,10 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../__helpers__/test-utils'; -import { mockGitifyNotification } from '../../__mocks__/notifications-mocks'; +import { + mockGiteaGitifyNotification, + mockGitifyNotification, +} from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; import { GroupBy } from '../../types'; @@ -260,5 +263,18 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { expect(unsubscribeNotificationMock).toHaveBeenCalledTimes(1); }); + + it('should not show unsubscribe for Gitea notifications', () => { + const props: NotificationRowProps = { + notification: mockGiteaGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + renderWithProviders(); + + expect( + screen.queryByTestId('notification-unsubscribe-from-thread'), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index 579ac8680..b36132fde 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -10,7 +10,10 @@ import { HoverGroup } from '../primitives/HoverGroup'; import { type GitifyNotification, Opacity, Size } from '../../types'; -import { isMarkAsDoneFeatureSupported } from '../../utils/api/features'; +import { + isIgnoreThreadSubscriptionSupported, + isMarkAsDoneFeatureSupported, +} from '../../utils/api/features'; import { isGroupByDate } from '../../utils/notifications/group'; import { shouldRemoveNotificationsFromState } from '../../utils/notifications/remove'; import { openNotification } from '../../utils/system/links'; @@ -154,6 +157,7 @@ export const NotificationRow: FC = ({ { 'GitHub App', 'token', 'github.com', + 'github', ); }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index dd3630a42..7da6035ca 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -21,6 +21,7 @@ import type { Account, AccountNotifications, AuthState, + Forge, GitifyError, GitifyNotification, Hostname, @@ -72,6 +73,15 @@ import { import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/ui/zoom'; import { defaultAuth, defaultSettings } from './defaults'; +function normalizeAuthState(auth: AuthState): AuthState { + return { + accounts: auth.accounts.map((a) => ({ + ...a, + forge: a.forge ?? ('github' as Forge), + })), + }; +} + export interface AppContextState { auth: AuthState; isLoggedIn: boolean; @@ -130,7 +140,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { const [auth, setAuth] = useState( existingState.auth - ? { ...defaultAuth, ...existingState.auth } + ? { ...defaultAuth, ...normalizeAuthState(existingState.auth) } : defaultAuth, ); @@ -470,7 +480,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { await removeAccountNotifications(existingAccount); } - const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + const updatedAuth = await addAccount( + auth, + 'GitHub App', + token, + hostname, + 'github', + ); persistAuth(updatedAuth); await fetchNotifications({ auth: updatedAuth, settings }); @@ -504,6 +520,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { 'OAuth App', token, authOptions.hostname, + 'github', ); persistAuth(updatedAuth); @@ -522,15 +539,20 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { * Login with Personal Access Token (PAT). */ const loginWithPersonalAccessToken = useCallback( - async ({ token, hostname }: LoginPersonalAccessTokenOptions) => { + async ({ token, hostname, forge }: LoginPersonalAccessTokenOptions) => { + const resolvedForge: Forge = forge ?? 'github'; const encryptedToken = (await encryptValue(token)) as Token; await fetchAuthenticatedUserDetails({ hostname, token: encryptedToken, + forge: resolvedForge, } as Account); const existingAccount = auth.accounts.find( - (a) => a.hostname === hostname && a.method === 'Personal Access Token', + (a) => + a.hostname === hostname && + a.method === 'Personal Access Token' && + (a.forge ?? 'github') === resolvedForge, ); if (existingAccount) { await removeAccountNotifications(existingAccount); @@ -541,6 +563,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { 'Personal Access Token', token, hostname, + resolvedForge, ); persistAuth(updatedAuth); diff --git a/src/renderer/hooks/useNotifications.test.ts b/src/renderer/hooks/useNotifications.test.ts index 4c24e492c..c1fe80da0 100644 --- a/src/renderer/hooks/useNotifications.test.ts +++ b/src/renderer/hooks/useNotifications.test.ts @@ -5,6 +5,7 @@ import { mockGitHubEnterpriseServerAccount, } from '../__mocks__/account-mocks'; import { + mockGiteaGitifyNotification, mockGitHubCloudGitifyNotifications, mockGitifyNotification, mockMultipleAccountNotifications, @@ -529,25 +530,61 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(result.current.notifications.length).toBe(0); }); - it('should not mark as done when account does not support the feature', async () => { - // GitHub Enterprise Server without version doesn't support mark as done + it('should fall back to mark as read when mark-as-done is not supported', async () => { + const readSpy = vi + .spyOn(apiClient, 'markNotificationThreadAsRead') + .mockResolvedValue(undefined); + const doneSpy = vi + .spyOn(apiClient, 'markNotificationThreadAsDone') + .mockResolvedValue(undefined); + const mockEnterpriseNotification = { ...mockGitifyNotification, - account: mockGitHubEnterpriseServerAccount, // No version set + account: mockGitHubEnterpriseServerAccount, }; const { result } = renderHook(() => useNotifications()); - // The API should NOT be called when account doesn't support the feature act(() => { result.current.markNotificationsAsDone(mockState, [ mockEnterpriseNotification, ]); }); - // Status should remain 'success' (not change to 'loading' since we return early) - expect(result.current.status).toBe('success'); - // No API calls should have been made - nock will fail if unexpected calls are made + await waitFor(() => { + expect(result.current.status).toBe('success'); + }); + + expect(readSpy).toHaveBeenCalledWith( + mockGitHubEnterpriseServerAccount, + mockEnterpriseNotification.id, + ); + expect(doneSpy).not.toHaveBeenCalled(); + }); + + it('should fall back to mark as read for Gitea when mark-as-done is requested', async () => { + const readSpy = vi + .spyOn(apiClient, 'markNotificationThreadAsRead') + .mockResolvedValue(undefined); + const doneSpy = vi.spyOn(apiClient, 'markNotificationThreadAsDone'); + + const { result } = renderHook(() => useNotifications()); + + act(() => { + result.current.markNotificationsAsDone(mockState, [ + mockGiteaGitifyNotification, + ]); + }); + + await waitFor(() => { + expect(result.current.status).toBe('success'); + }); + + expect(readSpy).toHaveBeenCalledWith( + mockGiteaGitifyNotification.account, + mockGiteaGitifyNotification.id, + ); + expect(doneSpy).not.toHaveBeenCalled(); }); it('should mark notifications as done with failure', async () => { @@ -599,6 +636,27 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(result.current.notifications.length).toBe(0); }); + it('should not call unsubscribe APIs when thread ignore is unsupported (Gitea)', async () => { + const ignoreSpy = vi.spyOn( + apiClient, + 'ignoreNotificationThreadSubscription', + ); + const readSpy = vi.spyOn(apiClient, 'markNotificationThreadAsRead'); + + const { result } = renderHook(() => useNotifications()); + + act(() => { + result.current.unsubscribeNotification( + mockState, + mockGiteaGitifyNotification, + ); + }); + + expect(ignoreSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + expect(result.current.status).toBe('success'); + }); + it('should unsubscribe from a notification with success - markAsDoneOnUnsubscribe = true', async () => { vi.spyOn( apiClient, diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index 3c924c447..fae77bac3 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -14,7 +14,10 @@ import { markNotificationThreadAsDone, markNotificationThreadAsRead, } from '../utils/api/client'; -import { isMarkAsDoneFeatureSupported } from '../utils/api/features'; +import { + isIgnoreThreadSubscriptionSupported, + isMarkAsDoneFeatureSupported, +} from '../utils/api/features'; import { getAccountUUID } from '../utils/auth/utils'; import { areAllAccountErrorsSame, @@ -195,10 +198,12 @@ export const useNotifications = (): NotificationsState => { const markNotificationsAsDone = useCallback( async (state: GitifyState, doneNotifications: GitifyNotification[]) => { - if ( - !state.settings || - !isMarkAsDoneFeatureSupported(doneNotifications[0].account) - ) { + if (!state.settings) { + return; + } + + if (!isMarkAsDoneFeatureSupported(doneNotifications[0].account)) { + await markNotificationsAsRead(state, doneNotifications); return; } @@ -229,7 +234,7 @@ export const useNotifications = (): NotificationsState => { setStatus('success'); }, - [notifications], + [notifications, markNotificationsAsRead], ); const unsubscribeNotification = useCallback( @@ -238,6 +243,10 @@ export const useNotifications = (): NotificationsState => { return; } + if (!isIgnoreThreadSubscriptionSupported(notification.account)) { + return; + } + setStatus('loading'); try { diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 1eb4462d1..200ee02ee 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -257,6 +257,26 @@ describe('renderer/routes/Accounts.tsx', () => { ); }); + it('should show login with Gitea personal access token', async () => { + await act(async () => { + renderWithProviders(, { + auth: { accounts: [mockOAuthAccount] }, + }); + }); + + await userEvent.click(screen.getByTestId('account-add-new')); + await userEvent.click(screen.getByTestId('account-add-gitea-pat')); + + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith( + '/login-personal-access-token', + { + replace: true, + state: { forge: 'gitea' }, + }, + ); + }); + it('should show login with oauth app', async () => { await act(async () => { renderWithProviders(, { diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 096f55e49..b2a2645a2 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -7,6 +7,7 @@ import { MarkGithubIcon, PersonAddIcon, PersonIcon, + ServerIcon, ShieldCheckIcon, SignOutIcon, StarFillIcon, @@ -112,6 +113,13 @@ export const AccountsRoute: FC = () => { return navigate('/login-personal-access-token', { replace: true }); }; + const loginWithGiteaPersonalAccessToken = () => { + return navigate('/login-personal-access-token', { + replace: true, + state: { forge: 'gitea' as const }, + }); + }; + const loginWithOAuthApp = () => { return navigate('/login-oauth-app', { replace: true }); }; @@ -344,7 +352,17 @@ export const AccountsRoute: FC = () => { - Login with Personal Access Token + GitHub (Personal Access Token) + + + loginWithGiteaPersonalAccessToken()} + > + + + + Gitea (Personal Access Token) { - Login with OAuth App + GitHub (OAuth App) diff --git a/src/renderer/routes/Login.test.tsx b/src/renderer/routes/Login.test.tsx index 2bbd0de35..48a85864b 100644 --- a/src/renderer/routes/Login.test.tsx +++ b/src/renderer/routes/Login.test.tsx @@ -32,6 +32,7 @@ describe('renderer/routes/Login.tsx', () => { isLoggedIn: false, }); + await userEvent.click(screen.getByTestId('login-section-github-toggle')); await userEvent.click(screen.getByTestId('login-github')); expect(navigateMock).toHaveBeenCalledTimes(1); @@ -41,15 +42,29 @@ describe('renderer/routes/Login.tsx', () => { it('should navigate to login with personal access token', async () => { renderWithProviders(, { isLoggedIn: false }); + await userEvent.click(screen.getByTestId('login-section-github-toggle')); await userEvent.click(screen.getByTestId('login-pat')); expect(navigateMock).toHaveBeenCalledTimes(1); expect(navigateMock).toHaveBeenCalledWith('/login-personal-access-token'); }); + it('should navigate to login with Gitea personal access token', async () => { + renderWithProviders(, { isLoggedIn: false }); + + await userEvent.click(screen.getByTestId('login-section-gitea-toggle')); + await userEvent.click(screen.getByTestId('login-gitea-pat')); + + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/login-personal-access-token', { + state: { forge: 'gitea' }, + }); + }); + it('should navigate to login with oauth app', async () => { renderWithProviders(, { isLoggedIn: false }); + await userEvent.click(screen.getByTestId('login-section-github-toggle')); await userEvent.click(screen.getByTestId('login-oauth-app')); expect(navigateMock).toHaveBeenCalledTimes(1); diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index 4e0183278..47d9e6def 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,9 +1,18 @@ -import { type FC, useEffect } from 'react'; +import { type FC, type ReactNode, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; +import { + ChevronDownIcon, + ChevronRightIcon, + KeyIcon, + MarkGithubIcon, + PersonIcon, + ServerIcon, +} from '@primer/octicons-react'; import { Button, Heading, Stack, Text } from '@primer/react'; +import { APPLICATION } from '../../shared/constants'; + import { useAppContext } from '../hooks/useAppContext'; import { LogoIcon } from '../components/icons/LogoIcon'; @@ -13,9 +22,44 @@ import { Size } from '../types'; import { showWindow } from '../utils/system/comms'; +function CollapsibleSection(props: { + title: string; + testIdToggle: string; + expanded: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( + + + {props.title} + {props.expanded ? ( + + ) : ( + + )} + + {props.expanded ? ( + + {props.children} + + ) : null} + + ); +} + export const LoginRoute: FC = () => { const navigate = useNavigate(); + const [githubOpen, setGithubOpen] = useState(false); + const [giteaOpen, setGiteaOpen] = useState(false); + const { isLoggedIn } = useAppContext(); useEffect(() => { @@ -27,41 +71,85 @@ export const LoginRoute: FC = () => { return ( - + - - GitHub Notifications - on your menu bar + + {APPLICATION.NAME} + + Notifications from GitHub and Gitea in your menu bar. + - - Login with - - navigate('/login-device-flow')} - variant="primary" + + setGithubOpen((o) => !o)} + testIdToggle="login-section-github-toggle" + title="GitHub" > - GitHub - + + navigate('/login-device-flow')} + variant="primary" + > + Device login + - navigate('/login-personal-access-token')} - > - Personal Access Token - + navigate('/login-personal-access-token')} + > + Personal Access Token + + + navigate('/login-oauth-app')} + > + OAuth App + + + - navigate('/login-oauth-app')} + setGiteaOpen((o) => !o)} + testIdToggle="login-section-gitea-toggle" + title="Gitea" > - OAuth App - + + + navigate('/login-personal-access-token', { + state: { forge: 'gitea' as const }, + }) + } + > + Personal Access Token + + + diff --git a/src/renderer/routes/LoginWithOAuthApp.tsx b/src/renderer/routes/LoginWithOAuthApp.tsx index 79a88cf67..7508748a7 100644 --- a/src/renderer/routes/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/LoginWithOAuthApp.tsx @@ -146,7 +146,7 @@ export const LoginWithOAuthAppRoute: FC = () => { return ( - Login with OAuth App + GitHub (OAuth App) {errors.invalidCredentialsForHost && ( diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.test.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.test.tsx index eb95957f4..7db8d5cd7 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.test.tsx +++ b/src/renderer/routes/LoginWithPersonalAccessToken.test.tsx @@ -40,8 +40,10 @@ describe('renderer/routes/LoginWithPersonalAccessToken.tsx', () => { hostname: null as unknown as Hostname, token: null as unknown as Token, }; - expect(validateForm(values).hostname).toBe('Hostname is required'); - expect(validateForm(values).token).toBe('Token is required'); + expect(validateForm(values, 'github').hostname).toBe( + 'Hostname is required', + ); + expect(validateForm(values, 'github').token).toBe('Token is required'); }); it('should validate the form values are correct format', () => { @@ -50,8 +52,12 @@ describe('renderer/routes/LoginWithPersonalAccessToken.tsx', () => { token: '!@£INVALID-.1' as Token, }; - expect(validateForm(values).hostname).toBe('Hostname format is invalid'); - expect(validateForm(values).token).toBe('Token format is invalid'); + expect(validateForm(values, 'github').hostname).toBe( + 'Hostname format is invalid', + ); + expect(validateForm(values, 'github').token).toBe( + 'Token format is invalid', + ); }); }); diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.tsx index 6b364ff36..f7ee3de88 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/LoginWithPersonalAccessToken.tsx @@ -27,12 +27,12 @@ import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; -import type { Account, Hostname, Token } from '../types'; -import type { LoginPersonalAccessTokenOptions } from '../utils/auth/types'; +import type { Account, Forge, Hostname, Link, Token } from '../types'; +import { isForgeGitea } from '../utils/auth/forge'; import { formatRecommendedOAuthScopes } from '../utils/auth/scopes'; import { - getNewTokenURL, + getPersonalAccessTokenSettingsUrl, isValidHostname, isValidToken, } from '../utils/auth/utils'; @@ -41,6 +41,7 @@ import { openExternalLink } from '../utils/system/comms'; interface LocationState { account?: Account; + forge?: Forge; } export interface IFormData { @@ -54,7 +55,7 @@ interface IFormErrors { invalidCredentialsForHost?: string; } -export const validateForm = (values: IFormData): IFormErrors => { +export const validateForm = (values: IFormData, forge: Forge): IFormErrors => { const errors: IFormErrors = {}; if (!values.hostname) { @@ -65,7 +66,7 @@ export const validateForm = (values: IFormData): IFormErrors => { if (!values.token) { errors.token = 'Token is required'; - } else if (!isValidToken(values.token)) { + } else if (!isValidToken(values.token, forge)) { errors.token = 'Token format is invalid'; } @@ -75,7 +76,10 @@ export const validateForm = (values: IFormData): IFormErrors => { export const LoginWithPersonalAccessTokenRoute: FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { account: reAuthAccount } = (location.state ?? {}) as LocationState; + const { account: reAuthAccount, forge: stateForge } = (location.state ?? + {}) as LocationState; + + const forge: Forge = reAuthAccount?.forge ?? stateForge ?? 'github'; const { loginWithPersonalAccessToken } = useAppContext(); @@ -84,7 +88,9 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); const [formData, setFormData] = useState({ - hostname: reAuthAccount?.hostname ?? Constants.GITHUB_HOSTNAME, + hostname: + reAuthAccount?.hostname ?? + (isForgeGitea(forge) ? ('' as Hostname) : Constants.GITHUB_HOSTNAME), token: '' as Token, } as IFormData); @@ -92,7 +98,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const handleSubmit = async () => { setIsVerifyingCredentials(true); - const newErrors = validateForm(formData); + const newErrors = validateForm(formData, forge); setErrors(newErrors); @@ -113,9 +119,11 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const verifyLoginCredentials = useCallback( async (data: IFormData) => { try { - await loginWithPersonalAccessToken( - data as LoginPersonalAccessTokenOptions, - ); + await loginWithPersonalAccessToken({ + hostname: data.hostname, + token: data.token, + forge, + }); navigate('/'); } catch (err) { rendererLogError( @@ -128,12 +136,16 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { }); } }, - [loginWithPersonalAccessToken], + [loginWithPersonalAccessToken, forge], ); + const pageTitle = isForgeGitea(forge) + ? 'Login to Gitea with Personal Access Token' + : 'Login with Personal Access Token'; + return ( - Login with Personal Access Token + {pageTitle} {errors.invalidCredentialsForHost && ( @@ -156,7 +168,9 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { Hostname - Change only if you are using GitHub Enterprise Server + {isForgeGitea(forge) + ? 'Your Gitea instance hostname (for example gitea.example.com)' + : 'Change only if you are using GitHub Enterprise Server'} { data-testid="login-hostname" name="hostname" onChange={handleInputChange} - placeholder="github.com" + placeholder={ + isForgeGitea(forge) ? 'gitea.example.com' : 'github.com' + } value={formData.hostname} /> {errors.hostname && ( @@ -182,26 +198,32 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { disabled={!formData.hostname} leadingVisual={KeyIcon} onClick={() => - openExternalLink(getNewTokenURL(formData.hostname)) + openExternalLink( + getPersonalAccessTokenSettingsUrl(formData.hostname, forge), + ) } size="small" > - Generate a PAT + {isForgeGitea(forge) ? 'Open token settings' : 'Generate a PAT'} - on GitHub to paste the token below. + {isForgeGitea(forge) + ? 'on your Gitea instance to create a token, then paste it below.' + : 'on GitHub to paste the token below.'} - - The{' '} - - - recommended scopes - - {' '} - will be automatically selected for you. - + {!isForgeGitea(forge) && ( + + The{' '} + + + recommended scopes + + {' '} + will be automatically selected for you. + + )} @@ -218,7 +240,11 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { data-testid="login-token" name="token" onChange={handleInputChange} - placeholder="Your generated token (40 characters)" + placeholder={ + isForgeGitea(forge) + ? 'Your Gitea personal access token' + : 'Your generated token (40 characters)' + } trailingAction={ {