feat(settings): surface GitHub connection needs_reauth/revoked
This commit is contained in:
@@ -48,6 +48,36 @@ export const GithubIntegrationPanel = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
|
{connection && connection.status !== 'active' ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
connection.status === 'revoked'
|
||||||
|
? 'rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-600'
|
||||||
|
: 'rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{connection.status === 'revoked'
|
||||||
|
? 'GitHub App installation removed'
|
||||||
|
: 'GitHub access needs re-authorization'}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1'>
|
||||||
|
{connection.status === 'revoked'
|
||||||
|
? 'Reinstall the app to resume syncing.'
|
||||||
|
: 'Reconnect the app to resume syncing.'}
|
||||||
|
</p>
|
||||||
|
{installUrl ? (
|
||||||
|
<a
|
||||||
|
className='mt-2 inline-block underline'
|
||||||
|
href={installUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Reconnect GitHub App
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{connection ? (
|
{connection ? (
|
||||||
<div className='grid gap-2 text-sm'>
|
<div className='grid gap-2 text-sm'>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const labels: Record<string, string> = {
|
|||||||
checking: 'Checking',
|
checking: 'Checking',
|
||||||
conflict: 'Conflict',
|
conflict: 'Conflict',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
|
rate_limited: 'Rate limited',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
@@ -35,10 +36,16 @@ const variants: Record<
|
|||||||
active: 'default',
|
active: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Warning-tone statuses that need an amber accent the base variants don't
|
||||||
|
// provide. Matches the app's existing amber warning styling.
|
||||||
|
const classNames: Record<string, string> = {
|
||||||
|
rate_limited: 'border-amber-500/40 bg-amber-500/10 text-amber-700',
|
||||||
|
};
|
||||||
|
|
||||||
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
|
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
|
||||||
const value = status ?? 'unknown';
|
const value = status ?? 'unknown';
|
||||||
return (
|
return (
|
||||||
<Badge variant={variants[value] ?? 'outline'}>
|
<Badge variant={variants[value] ?? 'outline'} className={classNames[value]}>
|
||||||
{labels[value] ?? value.replaceAll('_', ' ')}
|
{labels[value] ?? value.replaceAll('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { getFunctionName } from 'convex/server';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { GithubIntegrationPanel } from '../../src/components/integrations/github-integration-panel';
|
||||||
|
|
||||||
|
const { mockUseAction, mockUseQuery } = vi.hoisted(() => ({
|
||||||
|
mockUseAction: vi.fn(),
|
||||||
|
mockUseQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('convex/react', () => ({
|
||||||
|
useAction: mockUseAction,
|
||||||
|
useQuery: mockUseQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ConnectionStatus = 'active' | 'needs_reauth' | 'revoked';
|
||||||
|
|
||||||
|
const setup = (status: ConnectionStatus) => {
|
||||||
|
mockUseAction.mockReturnValue(vi.fn());
|
||||||
|
const connectionName = getFunctionName(api.github.getConnection);
|
||||||
|
const installUrlName = getFunctionName(api.github.getInstallUrl);
|
||||||
|
mockUseQuery.mockImplementation((ref: Parameters<typeof getFunctionName>[0]) => {
|
||||||
|
const name = getFunctionName(ref);
|
||||||
|
if (name === connectionName) {
|
||||||
|
return {
|
||||||
|
_id: 'connection-1',
|
||||||
|
displayName: 'octocat',
|
||||||
|
installationId: '12345',
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name === installUrlName) {
|
||||||
|
return 'https://github.com/apps/spoon/installations/new';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GithubIntegrationPanel', () => {
|
||||||
|
it('shows the reconnect warning when the connection needs re-authorization', () => {
|
||||||
|
setup('needs_reauth');
|
||||||
|
render(<GithubIntegrationPanel />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/GitHub access needs re-authorization/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: /reconnect github app/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the removed-installation warning when the connection is revoked', () => {
|
||||||
|
setup('revoked');
|
||||||
|
render(<GithubIntegrationPanel />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/GitHub App installation removed/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: /reconnect github app/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the normal connected state without a warning when active', () => {
|
||||||
|
setup('active');
|
||||||
|
render(<GithubIntegrationPanel />);
|
||||||
|
|
||||||
|
expect(screen.getByText('octocat')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/needs re-authorization/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('link', { name: /reconnect github app/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { SpoonStatusBadge } from '../../src/components/spoons/spoon-status-badge';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('SpoonStatusBadge', () => {
|
||||||
|
it('renders a dedicated "Rate limited" label for the rate_limited status', () => {
|
||||||
|
render(<SpoonStatusBadge status='rate_limited' />);
|
||||||
|
expect(screen.getByText('Rate limited')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still humanizes unknown statuses via the fallback', () => {
|
||||||
|
render(<SpoonStatusBadge status={'some_future_state' as never} />);
|
||||||
|
expect(screen.getByText('some future state')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user