Skip to content

Commit 46e56b9

Browse files
authored
fix(settings): opens GitHub app settings for org access (#12)
* fix(settings): opens GitHub app settings for org access * fix(settings): moves focus listener cleanup to component lifecycle * fix(settings): uses openGitHubUrl wrapper, adds syncing test * fix(settings): adds clientId validation, URL guard, and missing tests * fix(settings): surfaces sync failure notification, tests disabled state
1 parent b2a8713 commit 46e56b9

File tree

4 files changed

+169
-90
lines changed

4 files changed

+169
-90
lines changed

src/app/components/settings/SettingsPage.tsx

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { useNavigate } from "@solidjs/router";
33
import { config, updateConfig } from "../../stores/config";
44
import { clearAuth } from "../../stores/auth";
55
import { clearCache } from "../../stores/cache";
6-
import { buildAuthorizeUrl, MERGE_ORGS_KEY } from "../../lib/oauth";
6+
import { pushNotification } from "../../lib/errors";
7+
import { buildOrgAccessUrl } from "../../lib/oauth";
8+
import { isSafeGitHubUrl, openGitHubUrl } from "../../lib/url";
79
import { fetchOrgs } from "../../services/api";
810
import { getClient } from "../../services/github";
911
import OrgSelector from "../onboarding/OrgSelector";
@@ -153,14 +155,15 @@ export default function SettingsPage() {
153155
const [confirmClearCache, setConfirmClearCache] = createSignal(false);
154156
const [confirmReset, setConfirmReset] = createSignal(false);
155157
const [cacheClearing, setCacheClearing] = createSignal(false);
156-
const [reauthing, setReauthing] = createSignal(false);
158+
const [merging, setMerging] = createSignal(false);
157159
const [notifPermission, setNotifPermission] = createSignal<NotificationPermission>(
158160
typeof Notification !== "undefined" ? Notification.permission : "default"
159161
);
160162

161163
// Save indicator
162164
const [showSaved, setShowSaved] = createSignal(false);
163165
let saveTimer: ReturnType<typeof setTimeout> | undefined;
166+
let pendingFocusHandler: (() => void) | undefined;
164167

165168
function saveWithFeedback(patch: Parameters<typeof updateConfig>[0]) {
166169
updateConfig(patch);
@@ -169,7 +172,12 @@ export default function SettingsPage() {
169172
saveTimer = setTimeout(() => setShowSaved(false), 1500);
170173
}
171174

172-
onCleanup(() => clearTimeout(saveTimer));
175+
onCleanup(() => {
176+
clearTimeout(saveTimer);
177+
if (pendingFocusHandler) {
178+
window.removeEventListener("focus", pendingFocusHandler);
179+
}
180+
});
173181

174182
// Local copies for org/repo editing (committed on blur/change)
175183
const [localOrgs, setLocalOrgs] = createSignal<string[]>(config.selectedOrgs);
@@ -191,20 +199,14 @@ export default function SettingsPage() {
191199
};
192200
mq.addEventListener("change", handler);
193201
onCleanup(() => mq.removeEventListener("change", handler));
194-
195-
// Auto-merge newly accessible orgs after re-auth redirect
196-
const shouldMerge = sessionStorage.getItem(MERGE_ORGS_KEY);
197-
if (shouldMerge) {
198-
sessionStorage.removeItem(MERGE_ORGS_KEY);
199-
void mergeNewOrgs();
200-
}
201202
});
202203

203204
// ── Helpers ──────────────────────────────────────────────────────────────
204205

205206
async function mergeNewOrgs() {
206207
const client = getClient();
207208
if (!client) return;
209+
setMerging(true);
208210
const snapshot = [...config.selectedOrgs];
209211
try {
210212
const allOrgs = await fetchOrgs(client);
@@ -220,16 +222,27 @@ export default function SettingsPage() {
220222
console.info(`[settings] merged ${newOrgs.length} new org(s)`);
221223
}
222224
} catch {
223-
// Non-fatal — user can manually manage orgs
225+
pushNotification("org-sync", "Failed to sync organizations — try again or manage manually", "warning");
226+
} finally {
227+
setMerging(false);
224228
}
225229
}
226230

227-
function handleReAuth() {
228-
setReauthing(true);
229-
// Reset if navigation doesn't happen (e.g., beforeunload dialog blocks redirect)
230-
setTimeout(() => setReauthing(false), 3000);
231-
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
232-
window.location.href = buildAuthorizeUrl({ returnTo: "/settings" });
231+
function handleGrantOrgs() {
232+
const url = buildOrgAccessUrl();
233+
if (!isSafeGitHubUrl(url)) return;
234+
openGitHubUrl(url);
235+
// Remove any prior focus listener before adding a new one (dedup on rapid clicks)
236+
if (pendingFocusHandler) {
237+
window.removeEventListener("focus", pendingFocusHandler);
238+
}
239+
const onFocus = () => {
240+
window.removeEventListener("focus", onFocus);
241+
pendingFocusHandler = undefined;
242+
void mergeNewOrgs();
243+
};
244+
pendingFocusHandler = onFocus;
245+
window.addEventListener("focus", onFocus);
233246
}
234247

235248
function handleOrgsChange(orgs: string[]) {
@@ -408,16 +421,16 @@ export default function SettingsPage() {
408421
Organization Access
409422
</p>
410423
<p class="text-xs text-gray-500 dark:text-gray-400">
411-
Re-authorize with GitHub to grant access to additional organizations
424+
Manage organization access on GitHub — new orgs sync automatically when you return
412425
</p>
413426
</div>
414427
<button
415428
type="button"
416-
onClick={handleReAuth}
417-
disabled={reauthing()}
429+
onClick={handleGrantOrgs}
430+
disabled={merging()}
418431
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
419432
>
420-
Grant more orgs
433+
{merging() ? "Syncing..." : "Grant more orgs"}
421434
</button>
422435
</div>
423436
</div>

src/app/lib/oauth.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export const OAUTH_STATE_KEY = "github-tracker:oauth-state";
22
export const OAUTH_RETURN_TO_KEY = "github-tracker:oauth-return-to";
3-
export const MERGE_ORGS_KEY = "github-tracker:merge-orgs";
43

54
export function generateOAuthState(): string {
65
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
@@ -36,3 +35,13 @@ export function buildAuthorizeUrl(options?: { returnTo?: string }): string {
3635
});
3736
return `https://github.com/login/oauth/authorize?${params.toString()}`;
3837
}
38+
39+
const VALID_CLIENT_ID_RE = /^[A-Za-z0-9_-]+$/;
40+
41+
export function buildOrgAccessUrl(): string {
42+
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID as string;
43+
if (!clientId || !VALID_CLIENT_ID_RE.test(clientId)) {
44+
throw new Error("Invalid VITE_GITHUB_CLIENT_ID");
45+
}
46+
return `https://github.com/settings/connections/applications/${clientId}`;
47+
}

tests/components/settings/SettingsPage.test.tsx

Lines changed: 101 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ vi.mock("../../../src/app/services/api", () => ({
4141
fetchRepos: vi.fn().mockResolvedValue([]),
4242
}));
4343

44+
vi.mock("../../../src/app/lib/url", () => ({
45+
isSafeGitHubUrl: vi.fn(() => true),
46+
openGitHubUrl: vi.fn(),
47+
}));
48+
49+
vi.mock("../../../src/app/lib/errors", () => ({
50+
pushNotification: vi.fn(),
51+
}));
52+
4453
// ── Imports after mocks ───────────────────────────────────────────────────────
4554

4655
import { render } from "@solidjs/testing-library";
@@ -50,7 +59,8 @@ import * as authStore from "../../../src/app/stores/auth";
5059
import * as cacheStore from "../../../src/app/stores/cache";
5160
import * as apiModule from "../../../src/app/services/api";
5261
import { updateConfig, config } from "../../../src/app/stores/config";
53-
import { MERGE_ORGS_KEY, OAUTH_STATE_KEY } from "../../../src/app/lib/oauth";
62+
import { buildOrgAccessUrl } from "../../../src/app/lib/oauth";
63+
import * as urlModule from "../../../src/app/lib/url";
5464

5565
// ── Helpers ───────────────────────────────────────────────────────────────────
5666

@@ -89,6 +99,9 @@ beforeEach(() => {
8999
setupMatchMedia();
90100
vi.clearAllMocks();
91101

102+
// Restore isSafeGitHubUrl mock (vi.restoreAllMocks strips factory implementations)
103+
vi.mocked(urlModule.isSafeGitHubUrl).mockReturnValue(true);
104+
92105
// Reset config to defaults
93106
updateConfig({
94107
refreshInterval: 300,
@@ -104,7 +117,6 @@ beforeEach(() => {
104117
selectedRepos: [],
105118
});
106119

107-
// Clear sessionStorage — re-auth tests set MERGE_ORGS_KEY and OAUTH_STATE_KEY
108120
sessionStorage.clear();
109121

110122
// Mock window.location with both reload and href
@@ -521,126 +533,147 @@ describe("SettingsPage — Theme application", () => {
521533
});
522534
});
523535

524-
describe("SettingsPage — Re-auth button", () => {
536+
describe("SettingsPage — Grant more orgs button", () => {
537+
beforeEach(() => {
538+
vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
539+
});
540+
afterEach(() => {
541+
vi.unstubAllEnvs();
542+
});
543+
525544
it("renders 'Grant more orgs' button in Organizations & Repositories section", () => {
526545
renderSettings();
527546
screen.getByRole("button", { name: "Grant more orgs" });
528547
});
529548

530-
it("clicking 'Grant more orgs' sets MERGE_ORGS_KEY in sessionStorage", async () => {
549+
it("clicking 'Grant more orgs' opens GitHub app settings via openGitHubUrl", async () => {
531550
const user = userEvent.setup();
532-
vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
533551
renderSettings();
534552
const btn = screen.getByRole("button", { name: "Grant more orgs" });
535553
await user.click(btn);
536-
expect(sessionStorage.getItem(MERGE_ORGS_KEY)).toBe("true");
537-
vi.unstubAllEnvs();
554+
expect(urlModule.openGitHubUrl).toHaveBeenCalledWith(buildOrgAccessUrl());
538555
});
539556

540-
it("clicking 'Grant more orgs' sets window.location.href to GitHub authorize URL", async () => {
557+
it("clicking 'Grant more orgs' registers a focus listener for auto-merge", async () => {
541558
const user = userEvent.setup();
542-
vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
559+
const addSpy = vi.spyOn(window, "addEventListener");
543560
renderSettings();
544561
const btn = screen.getByRole("button", { name: "Grant more orgs" });
545562
await user.click(btn);
546-
expect(window.location.href).toContain("https://github.com/login/oauth/authorize");
547-
vi.unstubAllEnvs();
563+
expect(addSpy).toHaveBeenCalledWith("focus", expect.any(Function));
548564
});
549565

550-
it("clicking 'Grant more orgs' also sets OAUTH_STATE_KEY in sessionStorage", async () => {
566+
it("shows disabled 'Syncing...' button during merge, reverts after", async () => {
551567
const user = userEvent.setup();
552-
vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
553-
renderSettings();
554-
const btn = screen.getByRole("button", { name: "Grant more orgs" });
555-
await user.click(btn);
556-
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeTruthy();
557-
vi.unstubAllEnvs();
558-
});
559-
560-
it("'Grant more orgs' button is disabled after click and re-enables after timeout", async () => {
561-
vi.useFakeTimers();
562-
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
563-
vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
568+
updateConfig({ selectedOrgs: [] });
569+
let resolveFetch!: (v: never[]) => void;
570+
vi.mocked(apiModule.fetchOrgs).mockReturnValue(
571+
new Promise((r) => { resolveFetch = r as (v: never[]) => void; })
572+
);
564573
renderSettings();
565574
const btn = screen.getByRole("button", { name: "Grant more orgs" });
566575
await user.click(btn);
567-
expect(btn.hasAttribute("disabled")).toBe(true);
568-
vi.advanceTimersByTime(3000);
569-
expect(btn.hasAttribute("disabled")).toBe(false);
570-
vi.unstubAllEnvs();
571-
vi.useRealTimers();
576+
window.dispatchEvent(new Event("focus"));
577+
await waitFor(() => {
578+
const syncBtn = screen.getByRole("button", { name: "Syncing..." });
579+
expect(syncBtn.hasAttribute("disabled")).toBe(true);
580+
});
581+
resolveFetch([]);
582+
await waitFor(() => {
583+
const restored = screen.getByRole("button", { name: "Grant more orgs" });
584+
expect(restored.hasAttribute("disabled")).toBe(false);
585+
});
572586
});
573-
});
574587

575-
describe("SettingsPage — Auto-merge orgs on mount", () => {
576-
it("calls fetchOrgs and merges new orgs when MERGE_ORGS_KEY is in sessionStorage", async () => {
577-
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
588+
it("auto-merges new orgs when window regains focus after granting", async () => {
589+
const user = userEvent.setup();
578590
updateConfig({ selectedOrgs: ["existing-org"] });
579591
vi.mocked(apiModule.fetchOrgs).mockResolvedValue([
580592
{ login: "existing-org", avatarUrl: "", type: "org" },
581593
{ login: "new-org", avatarUrl: "", type: "org" },
582594
]);
583595
renderSettings();
596+
const btn = screen.getByRole("button", { name: "Grant more orgs" });
597+
await user.click(btn);
598+
window.dispatchEvent(new Event("focus"));
584599
await waitFor(() => {
585600
expect(apiModule.fetchOrgs).toHaveBeenCalled();
586601
});
587602
await waitFor(() => {
588603
expect(config.selectedOrgs).toContain("new-org");
604+
expect(config.selectedOrgs).toContain("existing-org");
589605
});
590606
});
591607

592-
it("preserves existing selectedOrgs when merging", async () => {
593-
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
608+
it("pushes warning notification on fetchOrgs failure", async () => {
609+
const { pushNotification } = await import("../../../src/app/lib/errors");
610+
const user = userEvent.setup();
594611
updateConfig({ selectedOrgs: ["existing-org"] });
595-
vi.mocked(apiModule.fetchOrgs).mockResolvedValue([
596-
{ login: "existing-org", avatarUrl: "", type: "org" },
597-
{ login: "new-org", avatarUrl: "", type: "org" },
598-
]);
612+
vi.mocked(apiModule.fetchOrgs).mockRejectedValue(new Error("Network error"));
599613
renderSettings();
614+
const btn = screen.getByRole("button", { name: "Grant more orgs" });
615+
await user.click(btn);
616+
window.dispatchEvent(new Event("focus"));
600617
await waitFor(() => {
601-
expect(config.selectedOrgs).toContain("existing-org");
618+
expect(apiModule.fetchOrgs).toHaveBeenCalled();
602619
});
603-
});
604-
605-
it("removes MERGE_ORGS_KEY from sessionStorage after processing", async () => {
606-
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
607-
updateConfig({ selectedOrgs: [] });
608-
vi.mocked(apiModule.fetchOrgs).mockResolvedValue([
609-
{ login: "new-org", avatarUrl: "", type: "org" },
610-
]);
611-
renderSettings();
612620
await waitFor(() => {
613-
expect(sessionStorage.getItem(MERGE_ORGS_KEY)).toBeNull();
621+
expect(pushNotification).toHaveBeenCalledWith(
622+
"org-sync",
623+
expect.stringContaining("Failed to sync"),
624+
"warning",
625+
);
614626
});
627+
expect(config.selectedOrgs).toEqual(["existing-org"]);
615628
});
616629

617-
it("does not call fetchOrgs when MERGE_ORGS_KEY is not in sessionStorage", () => {
618-
// No MERGE_ORGS_KEY set
630+
it("skips merge on focus when getClient returns null", async () => {
631+
const user = userEvent.setup();
632+
const github = await import("../../../src/app/services/github");
633+
vi.mocked(github.getClient).mockReturnValueOnce(null);
619634
renderSettings();
635+
const btn = screen.getByRole("button", { name: "Grant more orgs" });
636+
await user.click(btn);
637+
window.dispatchEvent(new Event("focus"));
638+
await new Promise((r) => setTimeout(r, 50));
620639
expect(apiModule.fetchOrgs).not.toHaveBeenCalled();
621640
});
622641

623-
it("silently handles fetchOrgs rejection without breaking", async () => {
624-
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
625-
updateConfig({ selectedOrgs: ["existing-org"] });
626-
vi.mocked(apiModule.fetchOrgs).mockRejectedValue(new Error("Network error"));
642+
it("rapid double-click deduplicates focus listeners", async () => {
643+
const user = userEvent.setup();
644+
const removeSpy = vi.spyOn(window, "removeEventListener");
627645
renderSettings();
628-
await waitFor(() => {
629-
expect(apiModule.fetchOrgs).toHaveBeenCalled();
630-
});
631-
// Config unchanged — error was swallowed
632-
expect(config.selectedOrgs).toEqual(["existing-org"]);
646+
const btn = screen.getByRole("button", { name: "Grant more orgs" });
647+
await user.click(btn);
648+
await user.click(btn);
649+
const focusRemoves = removeSpy.mock.calls.filter(([evt]) => evt === "focus");
650+
expect(focusRemoves.length).toBeGreaterThanOrEqual(1);
633651
});
634652

635-
it("skips merge when getClient returns null", async () => {
636-
sessionStorage.setItem(MERGE_ORGS_KEY, "true");
637-
const github = await import("../../../src/app/services/github");
638-
vi.mocked(github.getClient).mockReturnValueOnce(null);
653+
it("cleans up pending focus listener on component unmount", async () => {
654+
const user = userEvent.setup();
655+
const removeSpy = vi.spyOn(window, "removeEventListener");
656+
const { unmount } = renderSettings();
657+
const btn = screen.getByRole("button", { name: "Grant more orgs" });
658+
await user.click(btn);
659+
unmount();
660+
const focusRemoves = removeSpy.mock.calls.filter(([evt]) => evt === "focus");
661+
expect(focusRemoves.length).toBeGreaterThanOrEqual(1);
662+
});
663+
664+
it("focus listener self-removes — second focus does not re-trigger merge", async () => {
665+
const user = userEvent.setup();
666+
updateConfig({ selectedOrgs: [] });
667+
vi.mocked(apiModule.fetchOrgs).mockResolvedValue([]);
639668
renderSettings();
640-
// MERGE_ORGS_KEY still removed synchronously before mergeNewOrgs
669+
const btn = screen.getByRole("button", { name: "Grant more orgs" });
670+
await user.click(btn);
671+
window.dispatchEvent(new Event("focus"));
641672
await waitFor(() => {
642-
expect(sessionStorage.getItem(MERGE_ORGS_KEY)).toBeNull();
673+
expect(apiModule.fetchOrgs).toHaveBeenCalledTimes(1);
643674
});
644-
expect(apiModule.fetchOrgs).not.toHaveBeenCalled();
675+
window.dispatchEvent(new Event("focus"));
676+
await new Promise((r) => setTimeout(r, 50));
677+
expect(apiModule.fetchOrgs).toHaveBeenCalledTimes(1);
645678
});
646679
});

0 commit comments

Comments
 (0)