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
22 changes: 15 additions & 7 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,12 +78,19 @@ export const App = () => {
path="/account-scopes"
/>
<Route element={<LoginRoute />} path="/login" />
<Route element={<LoginWithDeviceFlowRoute />} path="/login-device-flow" />
<Route
element={<LoginWithPersonalAccessTokenRoute />}
path="/login-personal-access-token"
element={<GitHubLoginWithDeviceFlowRoute />}
path="/login/github/device-flow"
/>
<Route
element={<GitHubLoginWithPersonalAccessTokenRoute />}
path="/login/github/personal-access-token"
/>
<Route element={<GitHubLoginWithOAuthAppRoute />} path="/login/github/oauth-app" />
<Route
element={<GiteaLoginWithPersonalAccessTokenRoute />}
path="/login/gitea/personal-access-token"
/>
<Route element={<LoginWithOAuthAppRoute />} path="/login-oauth-app" />
</Routes>
</AppLayout>
</Router>
Expand Down
7 changes: 4 additions & 3 deletions src/renderer/__helpers__/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -18,7 +18,8 @@ const EMPTY_APP_CONTEXT: TestAppContext = {};
type TestAppContext = Partial<AppContextState>;

interface RenderOptions extends TestAppContext {
initialEntries?: string[];
/** Supports path strings or location objects (e.g. with `state` for re-auth). */
initialEntries?: InitialEntry[];
filters?: Partial<FiltersStore>;
}

Expand All @@ -28,7 +29,7 @@ interface RenderOptions extends TestAppContext {
interface AppContextProviderProps {
readonly children: ReactNode;
readonly value?: TestAppContext;
readonly initialEntries?: string[];
readonly initialEntries?: InitialEntry[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down Expand Up @@ -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<LoginWithPersonalAccessTokenFormProps> = ({
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();

Expand Down Expand Up @@ -116,9 +146,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => {

return (
<Page testId="Login With Personal Access Token">
<Header icon={KeyIcon}>
{isGitea ? 'Login to Gitea with Personal Access Token' : 'Login with Personal Access Token'}
</Header>
<Header icon={KeyIcon}>{title}</Header>

<Contents>
{errors.invalidCredentialsForHost && (
Expand All @@ -140,19 +168,15 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => {
<FormControl required>
<FormControl.Label>Hostname</FormControl.Label>
<FormControl.Caption>
<Text as="i">
{isGitea
? 'Your Gitea instance hostname (for example gitea.example.com)'
: 'Change only if you are using GitHub Enterprise Server'}
</Text>
<Text as="i">{hostnameCaption}</Text>
</FormControl.Caption>
<TextInput
aria-invalid={errors.hostname ? 'true' : 'false'}
block
data-testid="login-hostname"
name="hostname"
onChange={handleInputChange}
placeholder={isGitea ? 'gitea.example.com' : 'github.com'}
placeholder={hostnamePlaceholder}
value={formData.hostname}
/>
{errors.hostname && (
Expand All @@ -171,26 +195,12 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => {
}
size="small"
>
{isGitea ? 'Open token settings' : 'Generate a PAT'}
{tokenSettingsLabel}
</Button>
<Text className="text-xs">
{isGitea
? 'on your Gitea instance to create a token, then paste it below.'
: 'on GitHub to paste the token below.'}
</Text>
<Text className="text-xs">{tokenSettingsCaption}</Text>
</Stack>

{!isGitea && (
<Text as="i" className="text-xs">
The{' '}
<Tooltip direction="se" text={formatRecommendedOAuthScopes()}>
<button type="button">
<Text as="u">recommended scopes</Text>
</button>
</Tooltip>{' '}
will be automatically selected for you.
</Text>
)}
{children}
</Stack>

<FormControl required>
Expand All @@ -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={
<TextInput.Action
aria-label={shouldMaskPersonalAccessToken ? 'Show token' : 'Hide token'}
Expand All @@ -225,7 +231,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => {
</Contents>

<Footer justify="space-between">
<Tooltip direction="ne" text={isGitea ? 'Gitea API documentation' : 'GitHub documentation'}>
<Tooltip direction="ne" text={docsTooltip}>
<Button
data-testid="login-docs"
leadingVisual={BookIcon}
Expand Down
Loading
Loading