+ Connect your GitHub account + + {' '}— faster sign-in next time, and one less password to remember. + Code for Philly plans to retire password sign-in eventually. + +
+ + + from the link-flow
// callback, then strip the param so reloading doesn't re-toast.
@@ -115,44 +114,10 @@ export function Account() {
const sessions = sessionsQ.data?.data ?? [];
- const showConnectGitHubBanner =
- !bannerDismissed &&
- !hasGitHubLink &&
- (lastLoginMethod === 'legacy_password' || lastLoginMethod === 'password_reset');
-
return (
Account Settings
- {showConnectGitHubBanner && (
-
-
- Connect your GitHub account
-
- Faster sign-in next time, and one less password to remember. Code
- for Philly plans to retire password sign-in eventually — link
- GitHub now to stay ahead.
-
-
-
-
-
- )}
-
{/* Identity */}
diff --git a/apps/web/tests/Account.test.tsx b/apps/web/tests/Account.test.tsx
index 9c98a6a..774e8e5 100644
--- a/apps/web/tests/Account.test.tsx
+++ b/apps/web/tests/Account.test.tsx
@@ -1,17 +1,11 @@
/**
- * Account screen tests focused on the phase-D additions:
- * - Connect-GitHub banner renders only for legacy-credential users
- * who haven't linked yet.
- * - Identity card swaps "Manage on GitHub" for "Connect GitHub" when
- * hasGitHubLink is false.
- * - Banner dismiss button hides it for the rest of the session.
- *
- * The existing Account page predates this test file — these tests focus
- * narrowly on the banner + identity-card branches and leave the
- * newsletter / sessions / claim-legacy regions alone.
+ * Account screen tests focused on the GitHub-link affordances inside
+ * the Identity card. The persistent "Connect GitHub" nag banner has
+ * been hoisted to a top-level ConnectGitHubBanner component (rendered
+ * by AppShell on every page), and is covered by its own test file.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
-import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
import { renderScreen, mockOk } from './test-utils.js';
import { Account } from '../src/screens/Account.js';
import { AuthProvider } from '../src/hooks/useAuth.js';
@@ -80,50 +74,6 @@ function render() {
);
}
-describe('Account — Connect-GitHub banner', () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('renders for a legacy-password user with no GitHub link', async () => {
- mockApi(legacyPerson);
- render();
- await waitFor(() => {
- expect(
- screen.getByRole('region', { name: /connect github/i }),
- ).toBeInTheDocument();
- });
- // Banner has a Connect button + a Dismiss button
- const region = screen.getByRole('region', { name: /connect github/i });
- expect(region.querySelector('form[action="/api/auth/link-github"]')).not.toBeNull();
- expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
- });
-
- it('does not render for a github-signed-in user', async () => {
- mockApi(githubPerson);
- render();
- // Wait for the Identity card to render — that proves the page is past loading.
- await waitFor(() => {
- expect(screen.getByText(/connected — primary identity/i)).toBeInTheDocument();
- });
- expect(
- screen.queryByRole('region', { name: /connect github/i }),
- ).not.toBeInTheDocument();
- });
-
- it('dismiss button hides the banner for the rest of the session', async () => {
- mockApi(legacyPerson);
- render();
- const dismissBtn = await screen.findByRole('button', { name: /dismiss/i });
- fireEvent.click(dismissBtn);
- await waitFor(() => {
- expect(
- screen.queryByRole('region', { name: /connect github/i }),
- ).not.toBeInTheDocument();
- });
- });
-});
-
describe('Account — Identity card', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -135,9 +85,9 @@ describe('Account — Identity card', () => {
await waitFor(() => {
expect(screen.getByText(/not connected/i)).toBeInTheDocument();
});
- // Two forms post to the link endpoint: banner + identity card
+ // Identity card has a form posting to the link endpoint.
const forms = document.querySelectorAll('form[action="/api/auth/link-github"]');
- expect(forms.length).toBeGreaterThanOrEqual(2);
+ expect(forms.length).toBeGreaterThanOrEqual(1);
});
it('shows the "Manage on GitHub" link when hasGitHubLink is true', async () => {
@@ -149,7 +99,7 @@ describe('Account — Identity card', () => {
expect(
screen.getByRole('link', { name: /manage on github/i }),
).toHaveAttribute('href', 'https://github.com/settings');
- // No link-github form when already connected
+ // No link-github form when already connected.
expect(
document.querySelectorAll('form[action="/api/auth/link-github"]').length,
).toBe(0);
diff --git a/apps/web/tests/ConnectGitHubBanner.test.tsx b/apps/web/tests/ConnectGitHubBanner.test.tsx
new file mode 100644
index 0000000..6a21568
--- /dev/null
+++ b/apps/web/tests/ConnectGitHubBanner.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * Tests for ConnectGitHubBanner — the persistent "Connect your GitHub
+ * account" nag rendered directly under the navbar on every page.
+ *
+ * Visibility rule: signed-in + hasGitHubLink === false + lastLoginMethod
+ * ∈ {legacy_password, password_reset} + not dismissed.
+ */
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { renderScreen, mockOk } from './test-utils.js';
+import { ConnectGitHubBanner } from '../src/components/ConnectGitHubBanner.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+interface MeShape {
+ person: { id: string; slug: string; fullName: string; accountLevel: string; avatarUrl: string | null } | null;
+ accountLevel: string;
+ hasGitHubLink: boolean;
+ lastLoginMethod: 'github' | 'legacy_password' | 'password_reset' | null;
+}
+
+function mockMe(me: MeShape): void {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockOk(me)), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+}
+
+const baseLegacyPerson: MeShape = {
+ person: {
+ id: '01951a3c-0000-7000-8000-0000ffffff01',
+ slug: 'legacy-user',
+ fullName: 'Legacy User',
+ accountLevel: 'user',
+ avatarUrl: null,
+ },
+ accountLevel: 'user',
+ hasGitHubLink: false,
+ lastLoginMethod: 'legacy_password',
+};
+
+const anonMe: MeShape = {
+ person: null,
+ accountLevel: 'anonymous',
+ hasGitHubLink: false,
+ lastLoginMethod: null,
+};
+
+function render() {
+ return renderScreen(
+
+
+ ,
+ );
+}
+
+describe('ConnectGitHubBanner', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders for legacy-password user with no GitHub link', async () => {
+ mockMe(baseLegacyPerson);
+ render();
+ await waitFor(() => {
+ expect(
+ screen.getByRole('region', { name: /connect github/i }),
+ ).toBeInTheDocument();
+ });
+ // CTA form posts to the link endpoint.
+ const region = screen.getByRole('region', { name: /connect github/i });
+ expect(region.querySelector('form[action="/api/auth/link-github"]')).not.toBeNull();
+ expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
+ });
+
+ it('renders for a user whose session was minted via password reset', async () => {
+ mockMe({ ...baseLegacyPerson, lastLoginMethod: 'password_reset' });
+ render();
+ await waitFor(() => {
+ expect(
+ screen.getByRole('region', { name: /connect github/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('does not render for a github-signed-in user', async () => {
+ mockMe({
+ ...baseLegacyPerson,
+ hasGitHubLink: true,
+ lastLoginMethod: 'github',
+ });
+ render();
+ // Wait for /api/auth/me to settle (loading → resolved). The
+ // simplest signal is a tick: a known-true assertion plus a short
+ // microtask gap.
+ await new Promise((r) => setTimeout(r, 0));
+ expect(
+ screen.queryByRole('region', { name: /connect github/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render for anonymous viewers', async () => {
+ mockMe(anonMe);
+ render();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(
+ screen.queryByRole('region', { name: /connect github/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('hides after the user clicks dismiss', async () => {
+ mockMe(baseLegacyPerson);
+ render();
+ const dismissBtn = await screen.findByRole('button', { name: /dismiss/i });
+ fireEvent.click(dismissBtn);
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('region', { name: /connect github/i }),
+ ).not.toBeInTheDocument();
+ });
+ });
+});