Skip to content

Commit 31442a5

Browse files
committed
Handle Login state properly
1 parent 21442eb commit 31442a5

7 files changed

Lines changed: 167 additions & 26 deletions

File tree

features/audit-scraping/audit-history-sync.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,27 @@ import {
55
} from "@/lib/storage/audit-storage";
66
import { sendRuntimeMessage } from "@/lib/browser/messages";
77
import { parseAuditHistory } from "./audit-history-parser";
8+
import { checkLoginRequired } from "./audit-page-parser";
89

910
const AUDIT_HISTORY_URL =
1011
"https://utdirect.utexas.edu/apps/degree/audits/submissions/history/";
1112

12-
export async function isLoggedIn(): Promise<boolean> {
13-
try {
14-
const response = await fetch(AUDIT_HISTORY_URL, { credentials: "include" });
15-
return response.ok && !response.redirected;
16-
} catch {
17-
return false;
18-
}
19-
}
20-
2113
export async function fetchAuditHistory(): Promise<AuditHistoryEntry[]> {
2214
const response = await fetch(AUDIT_HISTORY_URL, { credentials: "include" });
2315
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
16+
if (response.redirected) throw new Error("Not logged in to UT Direct");
2417

25-
return parseAuditHistory(
26-
new DOMParser().parseFromString(await response.text(), "text/html"),
18+
const document = new DOMParser().parseFromString(
19+
await response.text(),
20+
"text/html",
2721
);
22+
if (checkLoginRequired(document)) {
23+
throw new Error("Not logged in to UT Direct");
24+
}
25+
// A logged-in student who has never requested an audit gets a history page
26+
if (!document.querySelector("table")) return [];
27+
28+
return parseAuditHistory(document);
2829
}
2930

3031
async function fetchAndSaveAuditHistory(): Promise<AuditHistoryEntry[]> {

features/audit-scraping/background-controller.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
closeScraperWindow,
1212
createScraperTab,
1313
} from "./scraper-window";
14+
import {
15+
openLoginTab,
16+
refreshLoginState,
17+
registerSessionCookieWatcher,
18+
} from "./login-state";
1419

1520
type ScrapeFailure = Extract<
1621
ExtensionMessage,
@@ -283,6 +288,13 @@ function registerAuditNavigationHandlers(): void {
283288
}
284289

285290
async function runNewAudit(): Promise<boolean> {
291+
if (!(await refreshLoginState())) {
292+
// Session is gone — send the user to log in instead of clicking into a
293+
// dead page from a hidden tab.
294+
await openLoginTab();
295+
throw new Error("Not logged in to UT Direct");
296+
}
297+
286298
const tabs = await browser.tabs.query({ url: "*://utdirect.utexas.edu/*" });
287299
const existingTab = tabs.find((tab) => tab.url?.startsWith(NEW_AUDIT_URL));
288300
if (existingTab?.id !== undefined) {
@@ -317,4 +329,5 @@ async function runNewAudit(): Promise<boolean> {
317329
export function registerAuditBackgroundController(): void {
318330
registerAuditNavigationHandlers();
319331
registerAuditScrapingHandlers();
332+
registerSessionCookieWatcher();
320333
}

features/audit-scraping/content-controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import {
44
} from "@/lib/browser/messages";
55
import { checkLoginRequired, parseAuditPage } from "./audit-page-parser";
66
import { startAuditHistorySync } from "./audit-history-sync";
7+
import { recordLoginStateFromPage } from "./login-state";
78

89
export function startAuditContentController(document: Document): void {
10+
recordLoginStateFromPage(document);
11+
912
if (/^\/apps\/degree\/audits\/?$/.test(document.location.pathname)) {
1013
void startAuditHistorySync(document);
1114
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { storage } from "wxt/utils/storage";
2+
import { checkLoginRequired } from "./audit-page-parser";
3+
4+
// Single owner of UT Direct login state: a cached value for instant UI, a
5+
// live probe for truth, and event-driven writers that keep the cache fresh.
6+
// Callers never touch the storage key, probe URL, or cookie details directly.
7+
8+
const AUDIT_HOME_URL = "https://utdirect.utexas.edu/apps/degree/audits/";
9+
10+
// Any authenticated UT Direct endpoint works as a probe; the history page is
11+
// lightweight and already part of the audit flow.
12+
const LOGIN_PROBE_URL =
13+
"https://utdirect.utexas.edu/apps/degree/audits/submissions/history/";
14+
15+
// UT Direct's degree-audit session cookie. Its removal is a definitive
16+
// logged-out signal; its appearance is only a hint (server-side expiry can
17+
// leave a dead cookie behind), so a set event triggers a verify instead.
18+
const SESSION_COOKIE = "sessionid-degree-audits-production";
19+
20+
// Last-known login state. null = never determined (fresh install).
21+
// Kept in local: storage — login state is per-browser and must not sync.
22+
const createLoginStateItem = () =>
23+
storage.defineItem<boolean | null>("local:utdLoggedIn", {
24+
defaultValue: null,
25+
});
26+
let loginStateItem: ReturnType<typeof createLoginStateItem> | undefined;
27+
28+
function getLoginStateItem() {
29+
// Avoid touching extension storage when consumers only import this module.
30+
return (loginStateItem ??= createLoginStateItem());
31+
}
32+
33+
function saveLoginState(loggedIn: boolean): Promise<void> {
34+
return getLoginStateItem().setValue(loggedIn);
35+
}
36+
37+
// Live network check. Logged out → UT SSO redirects the request to the login
38+
// page, so a followed redirect (or any failure) means no valid session.
39+
async function isLoggedIn(): Promise<boolean> {
40+
try {
41+
const response = await fetch(LOGIN_PROBE_URL, { credentials: "include" });
42+
return response.ok && !response.redirected;
43+
} catch {
44+
return false;
45+
}
46+
}
47+
48+
// Instant, possibly-stale read for painting UI. Verify with
49+
// refreshLoginState() before actions that depend on being logged in.
50+
export function getCachedLoginState(): Promise<boolean | null> {
51+
return getLoginStateItem().getValue();
52+
}
53+
54+
export function watchLoginState(
55+
listener: (loggedIn: boolean | null) => void,
56+
): () => void {
57+
return getLoginStateItem().watch(listener);
58+
}
59+
60+
// Definitive check that also updates the cache.
61+
export async function refreshLoginState(): Promise<boolean> {
62+
const loggedIn = await isLoggedIn();
63+
await saveLoginState(loggedIn);
64+
return loggedIn;
65+
}
66+
67+
// Content-script writer. A real UT Direct page is a definitive signal:
68+
// a login form in the DOM means the session is gone.
69+
export function recordLoginStateFromPage(document: Document): void {
70+
void saveLoginState(!checkLoginRequired(document));
71+
}
72+
73+
// Event-driven cache updates from the background service worker: react the
74+
// moment the session cookie is removed or (re)created, instead of waiting for
75+
// the next popup open. Requires the "cookies" permission.
76+
export function registerSessionCookieWatcher(): void {
77+
if (!browser.cookies?.onChanged) return;
78+
79+
browser.cookies.onChanged.addListener(({ cookie, removed, cause }) => {
80+
if (cookie.name !== SESSION_COOKIE) return;
81+
if (removed) {
82+
// "overwrite" removals are immediately followed by a set event for the
83+
// replacement cookie — not a logout.
84+
if (cause !== "overwrite") void saveLoginState(false);
85+
} else {
86+
void refreshLoginState();
87+
}
88+
});
89+
}
90+
91+
// The one way to send a user to log in. The audit home doubles as the SSO
92+
// entry point and, after the redirect back, triggers the first history sync.
93+
export async function openLoginTab(): Promise<void> {
94+
await browser.tabs.create({ url: AUDIT_HOME_URL, active: true });
95+
}

features/popup/popup-app.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import React, { useCallback, useEffect, useState } from "react";
1212
import { browser } from "wxt/browser";
1313
import Button from "@/components/ui/button";
1414
import logo from "@/public/logo.png";
15-
import { isLoggedIn } from "@/features/audit-scraping/audit-history-sync";
15+
import {
16+
getCachedLoginState,
17+
openLoginTab,
18+
refreshLoginState,
19+
watchLoginState,
20+
} from "@/features/audit-scraping/login-state";
1621
import PopupAuditCard from "./popup-audit-card";
1722

18-
const AUDIT_HOME_URL = "https://utdirect.utexas.edu/apps/degree/audits/";
19-
2023
export default function App() {
2124
const [audits, setAudits] = useState<AuditHistoryEntry[]>([]);
2225
const [loading, setLoading] = useState(true);
@@ -46,8 +49,12 @@ export default function App() {
4649
.finally(() => setLoading(false));
4750
}, [applyAuditHistory]);
4851

52+
// chache login state.
4953
useEffect(() => {
50-
void isLoggedIn().then(setLoggedIn);
54+
void getCachedLoginState().then((cached) => {
55+
setLoggedIn((current) => current ?? cached ?? false);
56+
});
57+
void refreshLoginState().then(setLoggedIn);
5158
}, []);
5259

5360
useEffect(() => {
@@ -74,10 +81,16 @@ export default function App() {
7481
applyAuditHistory(data);
7582
});
7683

84+
// Follow login-state writes from other contexts (content script, background)
85+
const unwatchLoginState = watchLoginState((value) => {
86+
if (value !== null) setLoggedIn(value);
87+
});
88+
7789
browser.runtime.onMessage.addListener(listener);
7890
return () => {
7991
browser.runtime.onMessage.removeListener(listener);
8092
unwatchAuditHistory();
93+
unwatchLoginState();
8194
};
8295
}, [applyAuditHistory]);
8396

@@ -91,22 +104,29 @@ export default function App() {
91104
// Send message to background script to run audit (background has access to tabs/scripting APIs)
92105
const handleRerunAudit = async () => {
93106
setRunningAudit(true);
94-
sendRuntimeMessage({ type: "RUN_NEW_AUDIT" });
107+
const stillLoggedIn = await refreshLoginState();
108+
setLoggedIn(stillLoggedIn);
109+
if (!stillLoggedIn) {
110+
setRunningAudit(false);
111+
handleLogin();
112+
return;
113+
}
114+
const response = await sendRuntimeMessage({ type: "RUN_NEW_AUDIT" });
115+
// Background refuses when the session is dead (it opens the login page
116+
// instead) — don't leave the spinner running.
117+
if (response && !response.success) setRunningAudit(false);
95118
};
96119

97120
const handleLogin = () => {
98-
void browser.tabs.create({ url: AUDIT_HOME_URL, active: true });
121+
void openLoginTab();
99122
};
100123

101124
// Determine which audits to display
102125
const displayedAudits = showAll ? audits : audits.slice(0, 3);
103126
const hasMoreAudits = audits.length > 3;
104-
const needsLogin = loggedIn === false && audits.length === 0;
105-
// An authenticated empty history is a valid state, even if an older sync cached an error.
106-
const hasAuthenticatedEmptyHistory = loggedIn === true && audits.length === 0;
107-
// Only the empty state depends on auth (Login vs. empty); with cached audits we
108-
// can render immediately. So wait for auth ONLY when there's nothing to show yet.
109-
const resolvingLogin = loggedIn === null && audits.length === 0;
127+
// Login button shows whenever we're not logged in, even with cached audits.
128+
// null only lasts until the cache read resolves (milliseconds).
129+
const needsLogin = loggedIn === false;
110130

111131
return (
112132
<div className="w-[438px] h-full min-h-[300px] max-h-[600px] bg-background font-sans overflow-hidden flex flex-col border border-gray-100">
@@ -129,7 +149,7 @@ export default function App() {
129149
className="rounded-md"
130150
onClick={needsLogin ? handleLogin : handleRerunAudit}
131151
>
132-
{resolvingLogin ? (
152+
{loggedIn === null ? (
133153
<div className="flex items-center space-x-2">
134154
<SpinnerIcon size={24} className="animate-spin-slow" />
135155
</div>
@@ -176,7 +196,7 @@ export default function App() {
176196
<div className="flex flex-col gap-2 items-center justify-center text-center mb-6 py-8">
177197
<p className="text-base text-dap-gray-light">Syncing...</p>
178198
</div>
179-
) : needsLogin ? (
199+
) : needsLogin && audits.length === 0 ? (
180200
<div className="flex flex-col gap-2 items-center justify-center text-center mb-6 py-8">
181201
<p className="text-base text-dap-gray-light tracking-[0.32px] max-w-[300px]">
182202
Log in to UT Direct, then visit the Degree Audit page to load your

tests/audit-scraping/content-controller.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ let listener: ((message: ExtensionMessage) => void) | undefined;
66
let parseShouldThrow = false;
77
let syncCalls = 0;
88
let sentMessages: ExtensionMessage[] = [];
9+
let recordedLoginPages = 0;
910

1011
mock.module("../../features/audit-scraping/audit-history-sync", () => ({
1112
startAuditHistorySync: async () => {
1213
syncCalls++;
1314
},
1415
}));
16+
mock.module("../../features/audit-scraping/login-state", () => ({
17+
recordLoginStateFromPage: () => {
18+
recordedLoginPages++;
19+
},
20+
}));
1521
mock.module("../../features/audit-scraping/audit-page-parser", () => ({
1622
checkLoginRequired: () => false,
1723
parseAuditPage: () => {
@@ -47,6 +53,7 @@ beforeEach(() => {
4753
parseShouldThrow = false;
4854
sentMessages = [];
4955
syncCalls = 0;
56+
recordedLoginPages = 0;
5057
});
5158

5259
function createDocument(pathname: string, body = ""): Document {
@@ -58,6 +65,8 @@ function createDocument(pathname: string, body = ""): Document {
5865
test("only syncs audit history on the audit landing page", () => {
5966
startAuditContentController(createDocument("/apps/degree/audits/"));
6067
expect(syncCalls).toBe(1);
68+
// every page load records the login state it sees in the DOM
69+
expect(recordedLoginPages).toBe(1);
6170

6271
startAuditContentController(
6372
createDocument("/apps/degree/audits/results/12345/"),

wxt.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default defineConfig({
5252
"256": "icon/LHD Logo.png",
5353
},
5454

55-
permissions: ["storage", "tabs", "scripting", "windows"],
55+
permissions: ["storage", "tabs", "scripting", "windows", "cookies"],
5656
host_permissions: ["https://utdirect.utexas.edu/*"],
5757
optional_host_permissions: [],
5858
optional_permissions: [],

0 commit comments

Comments
 (0)