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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
## Features

- 🔔 Unified notifications from your Git platforms
- ⚒️ Multi-forge: GitHub Cloud, GitHub Enterprise, Gitea, Forgejo, Codeberg
- ⚒️ Multi-forge: GitHub Cloud, GitHub Enterprise, Gitea, Forgejo, Codeberg, Bitbucket Cloud
- 💻 Cross-platform: macOS, Windows, and Linux
- 🎨 Customizable settings, filters and themes
- 🖥️ Tray/menu bar integration
Expand All @@ -28,8 +28,8 @@ Gitify uses a forge adapter pattern so notifications can come from any compatibl
| **GitHub** Enterprise Server (≥ 3.13) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **GitHub** Enterprise Cloud with Data Residency | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Gitea** (incl. Forgejo, Codeberg) | ✅ | ✅ | ✅ | — | — | — |
| **Bitbucket** Cloud | ✅ | ✅ | ✅ | — | — | — |
| **GitLab** (todos) | 💭 | — | — | — | — | — |
| **Bitbucket** Cloud | 💭 | — | — | — | — | — |
| **Azure DevOps** | 💭 | — | — | — | — | — |
| **Gerrit** | 💭 | — | — | — | — | — |

Expand Down
5 changes: 5 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query';

import { AccountsRoute } from './routes/Accounts';
import { AccountScopesRoute } from './routes/AccountScopes';
import { BitbucketLoginWithPersonalAccessTokenRoute } from './routes/bitbucket/LoginWithPersonalAccessToken';
import { FiltersRoute } from './routes/Filters';
import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken';
import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow';
Expand Down Expand Up @@ -108,6 +109,10 @@ export const App = () => {
element={<GiteaLoginWithPersonalAccessTokenRoute />}
path="/login/gitea/personal-access-token"
/>
<Route
element={<BitbucketLoginWithPersonalAccessTokenRoute />}
path="/login/bitbucket/personal-access-token"
/>
</Routes>
</AppLayout>
</Router>
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/__mocks__/account-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export const mockGiteaAccount: Account = {
user: mockGitifyUser,
};

export const mockBitbucketAccount: Account = {
forge: 'bitbucket',
platform: 'Bitbucket Cloud',
method: 'Personal Access Token',
token: 'token-bitbucket' as Token,
hostname: 'bitbucket.org' as Hostname,
username: 'user@example.com',
user: mockGitifyUser,
};

export function mockAccountWithError(error: GitifyError): AccountNotifications {
return {
account: mockGitHubCloudAccount,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions src/renderer/components/icons/BitbucketIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { FC } from 'react';

import type { OcticonProps } from '@primer/octicons-react';

/**
* Bitbucket brand icon (black-and-white, uses currentColor).
* Implements FC<OcticonProps> so it is a drop-in for any place that expects an
* octicon-shaped component.
*/
export const BitbucketIcon: FC<OcticonProps> = ({
size = 16,
fill = 'currentColor',
className,
...props
}) => (
<svg
aria-hidden="true"
className={className}
fill={fill}
height={size}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z" />
</svg>
);
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ interface LocationState {
export interface IFormData {
token: Token;
hostname: Hostname;
username?: string;
}

interface IFormErrors {
token?: string;
hostname?: string;
username?: string;
invalidCredentialsForHost?: string;
}

Expand All @@ -43,6 +45,10 @@ export const validateForm = (values: IFormData, forge: Forge = 'github'): IFormE
errors.hostname = 'Hostname format is invalid';
}

if (forge === 'bitbucket' && !values.username) {
errors.username = 'Atlassian email is required';
}

if (!values.token) {
errors.token = 'Token is required';
} else if (!adapter.validateToken(values.token)) {
Expand All @@ -56,16 +62,26 @@ export interface LoginWithPersonalAccessTokenFormProps {
forge: Forge;
/** Page header title. */
title: string;
/** Caption below the hostname label. */
hostnameCaption: string;
hostnamePlaceholder: string;
/**
* When false the hostname field is hidden and the value from
* `adapter.defaultHostname` is used silently (e.g. Bitbucket, which is
* always bitbucket.org).
*/
showHostnameField?: boolean;
/** Caption below the hostname label. Only used when showHostnameField is true. */
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;
/** When true, renders an email/username field above the token input (for Bitbucket). */
showUsernameField?: boolean;
usernamePlaceholder?: string;
usernameCaption?: string;
/** Forge-specific content rendered below the token settings row (e.g. scope hints). */
children?: ReactNode;
}
Expand All @@ -79,12 +95,16 @@ export interface LoginWithPersonalAccessTokenFormProps {
export const LoginWithPersonalAccessTokenForm: FC<LoginWithPersonalAccessTokenFormProps> = ({
forge,
title,
hostnameCaption,
hostnamePlaceholder,
hostnameCaption = '',
hostnamePlaceholder = '',
tokenSettingsLabel,
tokenSettingsCaption,
tokenPlaceholder,
docsTooltip,
showHostnameField = true,
showUsernameField = false,
usernamePlaceholder = '',
usernameCaption = '',
children,
}) => {
const navigate = useNavigate();
Expand All @@ -101,6 +121,7 @@ export const LoginWithPersonalAccessTokenForm: FC<LoginWithPersonalAccessTokenFo
const [formData, setFormData] = useState({
hostname: reAuthAccount?.hostname ?? adapter.defaultHostname ?? ('' as Hostname),
token: '' as Token,
username: reAuthAccount?.username ?? '',
} as IFormData);

const [errors, setErrors] = useState({} as IFormErrors);
Expand All @@ -111,7 +132,7 @@ export const LoginWithPersonalAccessTokenForm: FC<LoginWithPersonalAccessTokenFo

setErrors(newErrors);

if (!newErrors.hostname && !newErrors.token) {
if (!newErrors.hostname && !newErrors.token && !newErrors.username) {
verifyLoginCredentials(formData);
}
setIsVerifyingCredentials(false);
Expand All @@ -131,6 +152,7 @@ export const LoginWithPersonalAccessTokenForm: FC<LoginWithPersonalAccessTokenFo
await loginWithPersonalAccessToken({
hostname: data.hostname,
token: data.token,
...(data.username ? { username: data.username } : {}),
forge,
});
navigate('/');
Expand Down Expand Up @@ -165,24 +187,50 @@ export const LoginWithPersonalAccessTokenForm: FC<LoginWithPersonalAccessTokenFo
/>
)}
<Stack direction="vertical" gap="normal">
<FormControl required>
<FormControl.Label>Hostname</FormControl.Label>
<FormControl.Caption>
<Text as="i">{hostnameCaption}</Text>
</FormControl.Caption>
<TextInput
aria-invalid={errors.hostname ? 'true' : 'false'}
block
data-testid="login-hostname"
name="hostname"
onChange={handleInputChange}
placeholder={hostnamePlaceholder}
value={formData.hostname}
/>
{errors.hostname && (
<FormControl.Validation variant="error">{errors.hostname}</FormControl.Validation>
)}
</FormControl>
{showUsernameField && (
<FormControl required>
<FormControl.Label>Atlassian Email</FormControl.Label>
{usernameCaption && (
<FormControl.Caption>
<Text as="i">{usernameCaption}</Text>
</FormControl.Caption>
)}
<TextInput
aria-invalid={errors.username ? 'true' : 'false'}
block
data-testid="login-username"
name="username"
onChange={handleInputChange}
placeholder={usernamePlaceholder}
type="email"
value={formData.username ?? ''}
/>
{errors.username && (
<FormControl.Validation variant="error">{errors.username}</FormControl.Validation>
)}
</FormControl>
)}

{showHostnameField && (
<FormControl required>
<FormControl.Label>Hostname</FormControl.Label>
<FormControl.Caption>
<Text as="i">{hostnameCaption}</Text>
</FormControl.Caption>
<TextInput
aria-invalid={errors.hostname ? 'true' : 'false'}
block
data-testid="login-hostname"
name="hostname"
onChange={handleInputChange}
placeholder={hostnamePlaceholder}
value={formData.hostname}
/>
{errors.hostname && (
<FormControl.Validation variant="error">{errors.hostname}</FormControl.Validation>
)}
</FormControl>
)}

<Stack direction="vertical" gap="condensed">
<Stack align="center" direction="horizontal" gap="condensed">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { HoverGroup } from '../primitives/HoverGroup';

import { type Account, type GitifyError, type GitifyNotification, Size } from '../../types';

import { getAdapter } from '../../utils/forges/registry';
import {
groupNotificationsByRepository,
isGroupByRepository,
Expand Down Expand Up @@ -83,7 +84,7 @@ export const AccountNotifications: FC<AccountNotificationsProps> = (
>
<AvatarWithFallback
alt={account.user!.login}
name={`@${account.user!.login}`}
name={getAdapter(account).formatUserLogin(account.user!.login)}
size={Size.MEDIUM}
src={account.user!.avatar ?? undefined}
/>
Expand Down
32 changes: 31 additions & 1 deletion src/renderer/hooks/useLogins.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import { setNotificationsOverrides } from '../__helpers__/hook-mocks';
import { mockGitHubCloudAccount } from '../__mocks__/account-mocks';
import { mockBitbucketAccount, mockGitHubCloudAccount } from '../__mocks__/account-mocks';

import { Constants } from '../constants';

Expand Down Expand Up @@ -159,4 +159,34 @@ describe('renderer/hooks/useLogins.ts', () => {
expect(removeAccountNotificationsMock).toHaveBeenCalledWith(mockGitHubCloudAccount);
expect(removeAccountSpy).toHaveBeenCalledWith(mockGitHubCloudAccount);
});

it('loginWithPersonalAccessToken forwards username for Bitbucket accounts', async () => {
vi.spyOn(getAdapter('bitbucket'), 'fetchAuthenticatedUser').mockResolvedValue({
user: {
id: mockBitbucketAccount.user!.id,
login: mockBitbucketAccount.user!.login,
name: mockBitbucketAccount.user!.name,
avatar: mockBitbucketAccount.user!.avatar!,
},
});

const { result } = renderLoginsHook();

await act(async () => {
await result.current.loginWithPersonalAccessToken({
token: mockBitbucketAccount.token,
hostname: mockBitbucketAccount.hostname,
forge: 'bitbucket',
username: 'user@example.com',
});
});

expect(createAccountSpy).toHaveBeenCalledWith(
'Personal Access Token',
mockBitbucketAccount.token,
mockBitbucketAccount.hostname,
'bitbucket',
'user@example.com',
);
});
});
5 changes: 3 additions & 2 deletions src/renderer/hooks/useLogins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,14 @@ export const useLogins = (): LoginsState => {
* Login with Personal Access Token (PAT).
*/
const loginWithPersonalAccessToken = useCallback(
async ({ token, hostname, forge }: LoginPersonalAccessTokenOptions) => {
async ({ token, hostname, forge, username }: LoginPersonalAccessTokenOptions) => {
const resolvedForge: Forge = forge ?? 'github';
const encryptedToken = (await encryptValue(token)) as Token;
await getAdapter(resolvedForge).fetchAuthenticatedUser({
forge: resolvedForge,
hostname,
token: encryptedToken,
username,
} as Account);

const existingAccount = accounts.find(
Expand All @@ -152,7 +153,7 @@ export const useLogins = (): LoginsState => {
await removeAccountNotifications(existingAccount);
}

await createAccount('Personal Access Token', token, hostname, resolvedForge);
await createAccount('Personal Access Token', token, hostname, resolvedForge, username);
},
[accounts, createAccount, removeAccountNotifications],
);
Expand Down
Loading