diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a83973fb1..1834213f0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7,10 +7,11 @@ import { useAppContext } from './hooks/useAppContext'; import { AccountsRoute } from './routes/Accounts'; import { AccountScopesRoute } from './routes/AccountScopes'; import { FiltersRoute } from './routes/Filters'; +import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken'; +import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow'; +import { GitHubLoginWithOAuthAppRoute } from './routes/github/LoginWithOAuthApp'; +import { GitHubLoginWithPersonalAccessTokenRoute } from './routes/github/LoginWithPersonalAccessToken'; import { LoginRoute } from './routes/Login'; -import { LoginWithDeviceFlowRoute } from './routes/LoginWithDeviceFlow'; -import { LoginWithOAuthAppRoute } from './routes/LoginWithOAuthApp'; -import { LoginWithPersonalAccessTokenRoute } from './routes/LoginWithPersonalAccessToken'; import { NotificationsRoute } from './routes/Notifications'; import { SettingsRoute } from './routes/Settings'; @@ -77,12 +78,19 @@ export const App = () => { path="/account-scopes" /> } path="/login" /> - } path="/login-device-flow" /> } - path="/login-personal-access-token" + element={} + path="/login/github/device-flow" + /> + } + path="/login/github/personal-access-token" + /> + } path="/login/github/oauth-app" /> + } + path="/login/gitea/personal-access-token" /> - } path="/login-oauth-app" /> diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index 9b0d08544..75d1d03ca 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; import { type ReactElement, type ReactNode, useMemo } from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { type InitialEntry, MemoryRouter } from 'react-router-dom'; import { mockAuth, mockSettings } from '../__mocks__/state-mocks'; @@ -18,7 +18,8 @@ const EMPTY_APP_CONTEXT: TestAppContext = {}; type TestAppContext = Partial; interface RenderOptions extends TestAppContext { - initialEntries?: string[]; + /** Supports path strings or location objects (e.g. with `state` for re-auth). */ + initialEntries?: InitialEntry[]; filters?: Partial; } @@ -28,7 +29,7 @@ interface RenderOptions extends TestAppContext { interface AppContextProviderProps { readonly children: ReactNode; readonly value?: TestAppContext; - readonly initialEntries?: string[]; + readonly initialEntries?: InitialEntry[]; } /** diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx similarity index 70% rename from src/renderer/routes/LoginWithPersonalAccessToken.tsx rename to src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx index 67525dd0a..c331a21ea 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx @@ -1,26 +1,24 @@ -import { type FC, useCallback, useState } from 'react'; +import { type FC, type ReactNode, useCallback, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { BookIcon, EyeClosedIcon, EyeIcon, KeyIcon, SignInIcon } from '@primer/octicons-react'; import { Banner, Button, FormControl, Stack, Text, TextInput, Tooltip } from '@primer/react'; -import { useAppContext } from '../hooks/useAppContext'; +import { useAppContext } from '../../hooks/useAppContext'; -import { Contents } from '../components/layout/Contents'; -import { Page } from '../components/layout/Page'; -import { Footer } from '../components/primitives/Footer'; -import { Header } from '../components/primitives/Header'; +import { Contents } from '../layout/Contents'; +import { Page } from '../layout/Page'; +import { Footer } from '../primitives/Footer'; +import { Header } from '../primitives/Header'; -import type { Account, Forge, Hostname, Token } from '../types'; -import type { LoginRouteState } from '../utils/forges/types'; +import type { Account, Forge, Hostname, Token } from '../../types'; -import { formatRecommendedOAuthScopes } from '../utils/auth/scopes'; -import { isValidHostname } from '../utils/auth/utils'; -import { rendererLogError, toError } from '../utils/core/logger'; -import { getAdapter } from '../utils/forges/registry'; -import { openExternalLink } from '../utils/system/comms'; +import { isValidHostname } from '../../utils/auth/utils'; +import { rendererLogError, toError } from '../../utils/core/logger'; +import { getAdapter } from '../../utils/forges/registry'; +import { openExternalLink } from '../../utils/system/comms'; -interface LocationState extends LoginRouteState { +interface LocationState { account?: Account; } @@ -54,14 +52,46 @@ export const validateForm = (values: IFormData, forge: Forge = 'github'): IFormE return errors; }; -export const LoginWithPersonalAccessTokenRoute: FC = () => { +export interface LoginWithPersonalAccessTokenFormProps { + forge: Forge; + /** Page header title. */ + title: string; + /** Caption below the hostname label. */ + hostnameCaption: string; + hostnamePlaceholder: string; + /** Label for the button that opens the forge's token settings page. */ + tokenSettingsLabel: string; + /** Text rendered beside the token settings button. */ + tokenSettingsCaption: string; + tokenPlaceholder: string; + /** Tooltip for the documentation button. */ + docsTooltip: string; + /** Forge-specific content rendered below the token settings row (e.g. scope hints). */ + children?: ReactNode; +} + +/** + * Shared personal-access-token login form. + * + * Forge-specific route components (`routes/github`, `routes/gitea`) supply + * their own copy via props so this form stays free of forge conditionals. + */ +export const LoginWithPersonalAccessTokenForm: FC = ({ + forge, + title, + hostnameCaption, + hostnamePlaceholder, + tokenSettingsLabel, + tokenSettingsCaption, + tokenPlaceholder, + docsTooltip, + children, +}) => { const navigate = useNavigate(); const location = useLocation(); - const { account: reAuthAccount, forge: stateForge } = (location.state ?? {}) as LocationState; + const { account: reAuthAccount } = (location.state ?? {}) as LocationState; - const forge: Forge = reAuthAccount?.forge ?? stateForge ?? 'github'; const adapter = getAdapter(forge); - const isGitea = forge === 'gitea'; const { loginWithPersonalAccessToken } = useAppContext(); @@ -116,9 +146,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { return ( -
- {isGitea ? 'Login to Gitea with Personal Access Token' : 'Login with Personal Access Token'} -
+
{title}
{errors.invalidCredentialsForHost && ( @@ -140,11 +168,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { Hostname - - {isGitea - ? 'Your Gitea instance hostname (for example gitea.example.com)' - : 'Change only if you are using GitHub Enterprise Server'} - + {hostnameCaption} { data-testid="login-hostname" name="hostname" onChange={handleInputChange} - placeholder={isGitea ? 'gitea.example.com' : 'github.com'} + placeholder={hostnamePlaceholder} value={formData.hostname} /> {errors.hostname && ( @@ -171,26 +195,12 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { } size="small" > - {isGitea ? 'Open token settings' : 'Generate a PAT'} + {tokenSettingsLabel} - - {isGitea - ? 'on your Gitea instance to create a token, then paste it below.' - : 'on GitHub to paste the token below.'} - + {tokenSettingsCaption} - {!isGitea && ( - - The{' '} - - - {' '} - will be automatically selected for you. - - )} + {children} @@ -202,11 +212,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { data-testid="login-token" name="token" onChange={handleInputChange} - placeholder={ - isGitea - ? 'Your Gitea personal access token' - : 'Your generated token (40 characters)' - } + placeholder={tokenPlaceholder} trailingAction={ {