Skip to content
Open
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
10 changes: 5 additions & 5 deletions src/renderer/context/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe('renderer/context/App.tsx', () => {

it('loginWithDeviceFlowStart delegates to the forge adapter', async () => {
const adapter = getAdapter('github');
const startSpy = vi.spyOn(adapter, 'startDeviceFlow').mockImplementation(vi.fn());
const startSpy = vi.spyOn(adapter.deviceFlow!, 'start').mockImplementation(vi.fn());

const getContext = renderWithContext();

Expand All @@ -227,7 +227,7 @@ describe('renderer/context/App.tsx', () => {

it('loginWithDeviceFlowPoll delegates to the forge adapter', async () => {
const adapter = getAdapter('github');
const pollSpy = vi.spyOn(adapter, 'pollDeviceFlow').mockImplementation(vi.fn());
const pollSpy = vi.spyOn(adapter.deviceFlow!, 'poll').mockImplementation(vi.fn());

const getContext = renderWithContext();

Expand Down Expand Up @@ -260,7 +260,7 @@ describe('renderer/context/App.tsx', () => {

it('loginWithOAuthApp delegates to the forge adapter', async () => {
const adapter = getAdapter('github');
const oauthSpy = vi.spyOn(adapter, 'performWebOAuth');
const oauthSpy = vi.spyOn(adapter.oauthWebApp!, 'performWebOAuth');

const getContext = renderWithContext();

Expand Down Expand Up @@ -291,7 +291,7 @@ describe('renderer/context/App.tsx', () => {
).rejects.toThrow(/Device flow is not supported for forge "gitea"/);
});

it('loginWithDeviceFlowComplete throws when the forge declares no auth method', async () => {
it('loginWithDeviceFlowComplete throws when the forge does not support device flow', async () => {
const getContext = renderWithContext();

await expect(
Expand All @@ -300,7 +300,7 @@ describe('renderer/context/App.tsx', () => {
'token' as Token,
Constants.GITHUB_HOSTNAME,
),
).rejects.toThrow(/did not declare a device-flow auth method/);
).rejects.toThrow(/does not support device flow/);
});

it('loginWithOAuthApp throws when the forge does not support OAuth app login', async () => {
Expand Down
28 changes: 14 additions & 14 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,11 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
*/
const loginWithDeviceFlowStart = useCallback(
async (forge: Forge, hostname?: Hostname, scopes?: string[]) => {
const adapter = getAdapter(forge);
if (!adapter.startDeviceFlow) {
const { deviceFlow } = getAdapter(forge);
if (!deviceFlow) {
throw new Error(`Device flow is not supported for forge "${forge}".`);
}
return await adapter.startDeviceFlow(hostname, scopes);
return await deviceFlow.start(hostname, scopes);
},
[],
);
Expand All @@ -454,23 +454,23 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
* Poll for completion of an OAuth device-flow session.
*/
const loginWithDeviceFlowPoll = useCallback(async (forge: Forge, session: DeviceFlowSession) => {
const adapter = getAdapter(forge);
if (!adapter.pollDeviceFlow) {
const { deviceFlow } = getAdapter(forge);
if (!deviceFlow) {
throw new Error(`Device flow is not supported for forge "${forge}".`);
}
return await adapter.pollDeviceFlow(session);
return await deviceFlow.poll(session);
}, []);

/**
* Finalise an OAuth device-flow session by recording the account.
*/
const loginWithDeviceFlowComplete = useCallback(
async (forge: Forge, token: Token, hostname: Hostname) => {
const adapter = getAdapter(forge);
const method = adapter.deviceFlowAuthMethod;
if (!method) {
throw new Error(`Forge "${forge}" did not declare a device-flow auth method.`);
const { deviceFlow } = getAdapter(forge);
if (!deviceFlow) {
throw new Error(`Forge "${forge}" does not support device flow.`);
}
const method = deviceFlow.authMethod;

const existingAccount = auth.accounts.find(
(a) => a.hostname === hostname && a.method === method,
Expand All @@ -492,13 +492,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
*/
const loginWithOAuthApp = useCallback(
async (forge: Forge, data: LoginOAuthWebOptions) => {
const adapter = getAdapter(forge);
if (!adapter.performWebOAuth || !adapter.exchangeAuthCodeForToken) {
const { oauthWebApp } = getAdapter(forge);
if (!oauthWebApp) {
throw new Error(`OAuth app login is not supported for forge "${forge}".`);
}

const { authOptions, authCode } = await adapter.performWebOAuth(data);
const token = await adapter.exchangeAuthCodeForToken(authCode, authOptions);
const { authOptions, authCode } = await oauthWebApp.performWebOAuth(data);
const token = await oauthWebApp.exchangeAuthCodeForToken(authCode, authOptions);

const existingAccount = auth.accounts.find(
(a) => a.hostname === authOptions.hostname && a.method === 'OAuth App',
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/routes/Accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export const AccountsRoute: FC = () => {
variant={i === 0 ? 'primary' : 'default'}
/>

{!hasBadCredentials && getAdapter(account).supportsOAuthScopes && (
{!hasBadCredentials && getAdapter(account).oauthScopes && (
<IconButton
aria-label={`View scopes for ${account.user?.login}`}
data-testid="account-view-scopes"
Expand Down
49 changes: 28 additions & 21 deletions src/renderer/routes/LoginWithDeviceFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
import { Footer } from '../components/primitives/Footer';
import { Header } from '../components/primitives/Header';

import type { Account, Forge, Link } from '../types';
import type { Account, Forge, Hostname, Link } from '../types';
import type { DeviceFlowSession } from '../utils/auth/types';

import { getAlternateScopeNames, getRecommendedScopeNames } from '../utils/auth/scopes';
import { rendererLogError, toError } from '../utils/core/logger';
import { getAdapter } from '../utils/forges/registry';
import { copyToClipboard, openExternalLink } from '../utils/system/comms';
import { openAccountSettings } from '../utils/system/links';

interface LocationState {
account?: Account;
Expand Down Expand Up @@ -229,25 +229,32 @@
</Stack>
</Button>

<Stack gap="none">
<Text as="em" size="small">
Note: to change previously granted permissions, revoke Gitify's access at{' '}
<button
className="text-gitify-link cursor-pointer"
onClick={() =>
openAccountSettings({
hostname: Constants.GITHUB_HOSTNAME,
method: 'GitHub App',
} as Account)
}
title="GitHub → Developer Settings"
type="button"
>
GitHub → Developer Settings
</button>
, then re-authorize above.
</Text>
</Stack>
{(() => {
const adapter = getAdapter(forge);
const revokeUrl = adapter.deviceFlow?.getRevokeAccessUrl(
(reAuthAccount?.hostname ?? Constants.GITHUB_HOSTNAME) as Hostname,

Check warning on line 235 in src/renderer/routes/LoginWithDeviceFlow.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=gitify-app_gitify&issues=AZ4aAA4WN8V99na92Hjj&open=AZ4aAA4WN8V99na92Hjj&pullRequest=2887
);
if (!revokeUrl) {
return null;
}
const label = `${adapter.displayName} → Developer Settings`;
return (
<Stack gap="none">
<Text as="em" size="small">
Note: to change previously granted permissions, revoke Gitify's access at{' '}
<button
className="text-gitify-link cursor-pointer"
onClick={() => openExternalLink(revokeUrl)}
title={label}
type="button"
>
{label}
</button>
, then re-authorize above.
</Text>
</Stack>
);
})()}
</Stack>
</Stack>
);
Expand Down
18 changes: 12 additions & 6 deletions src/renderer/routes/LoginWithOAuthApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { LoginOAuthWebOptions } from '../utils/auth/types';

import { isValidHostname } from '../utils/auth/utils';
import { rendererLogError, toError } from '../utils/core/logger';
import { getNewOAuthAppURL, isValidClientId, isValidToken } from '../utils/forges/github/auth';
import { getAdapter } from '../utils/forges/registry';
import { openExternalLink } from '../utils/system/comms';

interface LocationState {
Expand All @@ -37,8 +37,9 @@ interface IFormErrors {
invalidCredentialsForHost?: string;
}

export const validateForm = (values: IFormData): IFormErrors => {
export const validateForm = (values: IFormData, forge: Forge = 'github'): IFormErrors => {
const errors: IFormErrors = {};
const adapter = getAdapter(forge);

if (!values.hostname) {
errors.hostname = 'Hostname is required';
Expand All @@ -48,13 +49,13 @@ export const validateForm = (values: IFormData): IFormErrors => {

if (!values.clientId) {
errors.clientId = 'Client ID is required';
} else if (!isValidClientId(values.clientId)) {
} else if (!adapter.oauthWebApp?.validateClientId(values.clientId)) {
errors.clientId = 'Client ID format is invalid';
}

if (!values.clientSecret) {
errors.clientSecret = 'Client Secret is required';
} else if (!isValidToken(values.clientSecret as unknown as Token)) {
} else if (!adapter.validateToken(values.clientSecret as unknown as Token)) {
errors.clientSecret = 'Client Secret format is invalid';
}

Expand Down Expand Up @@ -83,7 +84,7 @@ export const LoginWithOAuthAppRoute: FC = () => {
const handleSubmit = async () => {
setIsVerifyingCredentials(true);

const newErrors = validateForm(formData);
const newErrors = validateForm(formData, forge);

setErrors(newErrors);

Expand Down Expand Up @@ -166,7 +167,12 @@ export const LoginWithOAuthAppRoute: FC = () => {
data-testid="login-create-oauth-app"
disabled={!formData.hostname}
leadingVisual={PersonIcon}
onClick={() => openExternalLink(getNewOAuthAppURL(formData.hostname))}
onClick={() => {
const url = getAdapter(forge).oauthWebApp?.getNewOAuthAppUrl(formData.hostname);
if (url) {
openExternalLink(url);
}
}}
size="small"
>
Create new OAuth App
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/utils/auth/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { getAdapter } from '../forges/registry';
* Return true if the account has all required OAuth scopes.
*
* Scope semantics live on the forge adapter — forges without an OAuth scope
* concept (e.g. Gitea) report `true` for every check.
* concept (e.g. Gitea) omit `oauthScopes`, and every check resolves to `true`.
*
* @param account - The account whose scopes to check.
*/
export function hasRequiredScopes(account: Account): boolean {
return getAdapter(account).hasRequiredScopes(account);
return getAdapter(account).oauthScopes?.hasRequired(account) ?? true;
}

/**
Expand All @@ -22,7 +22,7 @@ export function hasRequiredScopes(account: Account): boolean {
* @param account - The account whose scopes to check.
*/
export function hasRecommendedScopes(account: Account): boolean {
return getAdapter(account).hasRecommendedScopes(account);
return getAdapter(account).oauthScopes?.hasRecommended(account) ?? true;
}

/**
Expand All @@ -31,7 +31,7 @@ export function hasRecommendedScopes(account: Account): boolean {
* @param account - The account whose scopes to check.
*/
export function hasAlternateScopes(account: Account): boolean {
return getAdapter(account).hasAlternateScopes(account);
return getAdapter(account).oauthScopes?.hasAlternate(account) ?? true;
}

/**
Expand Down
8 changes: 3 additions & 5 deletions src/renderer/utils/forges/gitea/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,9 @@ describe('renderer/utils/forges/gitea/adapter.ts', () => {
});
});

describe('OAuth scope helpers', () => {
it('always reports every scope check as satisfied', () => {
expect(giteaAdapter.hasRequiredScopes(mockGiteaAccount)).toBe(true);
expect(giteaAdapter.hasRecommendedScopes(mockGiteaAccount)).toBe(true);
expect(giteaAdapter.hasAlternateScopes(mockGiteaAccount)).toBe(true);
describe('oauthScopes capability bundle', () => {
it('is omitted because Gitea has no OAuth scope concept', () => {
expect(giteaAdapter.oauthScopes).toBeUndefined();
});
});

Expand Down
9 changes: 2 additions & 7 deletions src/renderer/utils/forges/gitea/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ export const giteaAdapter: ForgeAdapter = {
// for those branches.
getAuthMethodIcon: () => KeyIcon,

supportsOAuthScopes: false,

loginMethods: [
{
testId: 'login-gitea-pat',
Expand All @@ -120,9 +118,6 @@ export const giteaAdapter: ForgeAdapter = {
},
],

// Gitea has no GitHub-style OAuth scope concept; treat any token as fully
// scoped so the recommended/alternate UI prompts never surface for Gitea.
hasRequiredScopes: () => true,
hasRecommendedScopes: () => true,
hasAlternateScopes: () => true,
// Gitea has no GitHub-style OAuth scope concept, so `oauthScopes` is
// omitted. Callers skip scopes UI when this is undefined.
};
49 changes: 30 additions & 19 deletions src/renderer/utils/forges/github/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@ describe('renderer/utils/forges/github/adapter.ts', () => {
});

it('wires the device-flow and OAuth-app methods so the context can dispatch via the adapter', () => {
expect(githubAdapter.deviceFlowAuthMethod).toBe('GitHub App');
expect(githubAdapter.startDeviceFlow).toBeDefined();
expect(githubAdapter.pollDeviceFlow).toBeDefined();
expect(githubAdapter.performWebOAuth).toBeDefined();
expect(githubAdapter.exchangeAuthCodeForToken).toBeDefined();
expect(githubAdapter.deviceFlow?.authMethod).toBe('GitHub App');
expect(githubAdapter.deviceFlow?.start).toBeDefined();
expect(githubAdapter.deviceFlow?.poll).toBeDefined();
expect(githubAdapter.deviceFlow?.getRevokeAccessUrl).toBeDefined();
expect(githubAdapter.oauthWebApp?.performWebOAuth).toBeDefined();
expect(githubAdapter.oauthWebApp?.exchangeAuthCodeForToken).toBeDefined();
expect(githubAdapter.oauthWebApp?.validateClientId).toBeDefined();
expect(githubAdapter.oauthWebApp?.getNewOAuthAppUrl).toBeDefined();
});
});

Expand Down Expand Up @@ -166,33 +169,41 @@ describe('renderer/utils/forges/github/adapter.ts', () => {
});
});

describe('OAuth scope helpers', () => {
describe('oauthScopes capability bundle', () => {
function withScopes(scopes: string[]) {
return { ...mockGitHubCloudAccount, scopes };
}

it('hasRequiredScopes is true when notifications + read:user are present', () => {
expect(githubAdapter.hasRequiredScopes(withScopes(['notifications', 'read:user']))).toBe(
true,
);
it('exposes the bundle (GitHub has an OAuth scope concept)', () => {
expect(githubAdapter.oauthScopes).toBeDefined();
});

it('hasRequiredScopes is false when a required scope is missing', () => {
expect(githubAdapter.hasRequiredScopes(withScopes(['notifications']))).toBe(false);
it('hasRequired is true when notifications + read:user are present', () => {
expect(
githubAdapter.oauthScopes!.hasRequired(withScopes(['notifications', 'read:user'])),
).toBe(true);
});

it('hasRecommendedScopes requires the full repo scope set', () => {
it('hasRequired is false when a required scope is missing', () => {
expect(githubAdapter.oauthScopes!.hasRequired(withScopes(['notifications']))).toBe(false);
});

it('hasRecommended requires the full repo scope set', () => {
expect(
githubAdapter.hasRecommendedScopes(withScopes(['notifications', 'read:user', 'repo'])),
githubAdapter.oauthScopes!.hasRecommended(
withScopes(['notifications', 'read:user', 'repo']),
),
).toBe(true);
expect(githubAdapter.hasRecommendedScopes(withScopes(['notifications', 'read:user']))).toBe(
false,
);
expect(
githubAdapter.oauthScopes!.hasRecommended(withScopes(['notifications', 'read:user'])),
).toBe(false);
});

it('hasAlternateScopes accepts public_repo as the legacy substitute', () => {
it('hasAlternate accepts public_repo as the legacy substitute', () => {
expect(
githubAdapter.hasAlternateScopes(withScopes(['notifications', 'read:user', 'public_repo'])),
githubAdapter.oauthScopes!.hasAlternate(
withScopes(['notifications', 'read:user', 'public_repo']),
),
).toBe(true);
});
});
Expand Down
Loading
Loading