diff --git a/src/components/Auth.tsx b/src/components/Auth.tsx new file mode 100644 index 00000000..272ba6d0 --- /dev/null +++ b/src/components/Auth.tsx @@ -0,0 +1,52 @@ +import { IconButton } from "@chakra-ui/react"; +import { Tooltip } from "./ui/tooltip"; + +import { LuLogIn, LuLogOut } from "react-icons/lu"; +import { Link, useLocation } from "react-router"; +import { useContext, useMemo } from "react"; +import { SessionContext } from "../lib/auth"; + +export function AuthButton() { + const session = useContext(SessionContext); + const location = useLocation(); + + const [tooltipContent, label, pathname, UserIcon, color] = useMemo(() => { + const username = session?.get("academic_id"); + + if (username) { + return [ + `Welcome ${username.split("@")[0]}! Click here to log out.`, + "Logout", + "/auth/logout", + LuLogOut, + "red", + ]; + } else { + return ["Click to log in!", "Login", "/auth/login", LuLogIn, "blue"]; + } + }, [session]); + + return ( + + + + + + + + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 00000000..c8cf29b6 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,271 @@ +import { createContext } from "react"; +import { createCookieSessionStorage } from "react-router"; + +// https://pilcrowonpaper.com/blog/oauth-guide/ + +export const FIREROAD_URL = import.meta.env.DEV + ? "https://fireroad-dev.mit.edu" + : "https://fireroad.mit.edu"; + +export const FIREROAD_LOGIN_URL = `${FIREROAD_URL}/login`; +export const FIREROAD_FETCH_TOKEN_URL = `${FIREROAD_URL}/fetch_token`; +export const FIREROAD_VERIFY_URL = `${FIREROAD_URL}/verify/`; + +export interface SessionData { + academic_id: string; + access_token: string; + current_semester: number; + success: boolean; + username: string; +} + +export interface SessionFlashData { + error: string; +} + +export const { getSession, commitSession, destroySession } = + createCookieSessionStorage({ + cookie: { + name: "__session", + path: "/", + sameSite: "lax", + httpOnly: import.meta.env.PROD, + secure: import.meta.env.PROD, + // since we don't send auth cookies to a server (since its all client-side), we don't need to sign them + secrets: [], + }, + }); + +export const SessionContext = createContext +> | null>(null); + +// API FUNCTION CALLS + +type GetFavoriteResponse = + | { + success: false; + error: string; + } + | { success: true; favorites: string[] }; + +export const getFavoriteCourses = async (authToken: string) => { + const response = await fetch(`${FIREROAD_URL}/prefs/favorites/`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch favorite courses"); + } + + const result = (await response.json()) as GetFavoriteResponse; + if (!result.success) { + throw new Error("Failed to fetch favorite courses: " + result.error); + } + + return result.favorites; +}; + +type SetFavoriteResponse = + | { + success: false; + error: string; + } + | { success: true }; + +export const setFavoriteCourses = async ( + authToken: string, + favorites: string[], +) => { + const response = await fetch(`${FIREROAD_URL}/prefs/set_favorites/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(favorites), + }); + + if (!response.ok) { + throw new Error("Failed to set favorite courses"); + } + + const result = (await response.json()) as SetFavoriteResponse; + if (!result.success) { + throw new Error("Failed to set favorite courses: " + result.error); + } +}; + +interface ScheduleContents { + selectedSubjects: { + units: string; + subject_id: string; + title: string; + allowedSections: { + Lecture?: number[]; + Recitation?: number[]; + Lab?: number[]; + Design?: number[]; + }; + selectedSections: { + Lecture?: number; + Recitation?: number; + Lab?: number; + Design?: number; + }; + }[]; +} + +type GetSchedulesWithIdResult = + | { + success: false; + error: string; + } + | { + success: true; + file: { + name: string; + id: string; + changed: string; + downloaded: string; + agent: string; + contents: ScheduleContents; + }; + }; + +type GetSchedulesWithoutIdResult = + | { + success: false; + error: string; + } + | { + success: true; + files: Record; + }; + +export const getSchedules = async (authToken: string, id?: string) => { + const response = await fetch( + `${FIREROAD_URL}/sync/schedules/${id ? `?id=${id}` : ""}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch schedules"); + } + + if (typeof id == "string") { + // id specified, return single schedule + const result = (await response.json()) as GetSchedulesWithIdResult; + if (!result.success) { + throw new Error("Failed to fetch schedule: " + result.error); + } + + return result.file; + } else { + // no id specified, return all schedules + const result = (await response.json()) as GetSchedulesWithoutIdResult; + if (!result.success) { + throw new Error("Failed to fetch schedules: " + result.error); + } + + return result.files; + } +}; + +type DeleteScheduleResult = + | { + success: false; + error: string; + } + | { success: true }; + +export const deleteSchedule = async (authToken: string, id: string) => { + const response = await fetch(`${FIREROAD_URL}/delete_schedule/`, { + method: "POST", + body: JSON.stringify({ id }), + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to delete schedule"); + } + + const result = (await response.json()) as DeleteScheduleResult; + if (!result.success) { + throw new Error("Failed to delete schedule: " + result.error); + } + + return result; +}; + +type SyncScheduleResult = + | { + success: false; + error: string; + } + | { success: true; result: "no_change"; changed: string } + | { success: true; result: "update_remote"; changed: string } + | { + success: true; + result: "update_local"; + contents: ScheduleContents; + name: string; + id: string; + downloaded: string; + } + | { + success: true; + result: "conflict"; + other_name: string; + other_agent: string; + other_date: string; + other_contents: ScheduleContents | ""; + this_agent: string; + this_date: string; + }; + +export const syncSchedule = async ( + authToken: string, + id: string, + contents: ScheduleContents, + changed: string, + downloaded?: string, + name?: string, + agent?: string, + override?: boolean, +) => { + const response = await fetch(`${FIREROAD_URL}/sync_schedule/`, { + method: "POST", + body: JSON.stringify({ + id, + contents, + changed, + downloaded, + name, + agent, + override, + }), + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to sync schedule"); + } + + const result = (await response.json()) as SyncScheduleResult; + if (!result.success) { + throw new Error("Failed to sync schedule: " + result.error); + } + + return result; +}; diff --git a/src/lib/state.ts b/src/lib/state.ts index 6cb1fcbe..e270aaab 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -19,6 +19,7 @@ import type { RawClass, RawTimeslot, RawPEClass, BuildingInfo } from "./raw"; import { Store } from "./store"; import { sum, urldecode, urlencode } from "./utils"; import type { HydrantState, Preferences, Save } from "./schema"; +import { getFavoriteCourses, setFavoriteCourses } from "./auth"; import { BANNER_LAST_CHANGED, DEFAULT_PREFERENCES } from "./schema"; import { PEClass } from "./pe"; @@ -67,6 +68,8 @@ export class State { private starredClasses = new Set(); /** Set of starred PE class numbers */ private starredPEClasses = new Set(); + /** Current access token */ + private accessToken?: string = undefined; /** React callback to update state. */ callback: ((state: HydrantState) => void) | undefined; @@ -346,7 +349,11 @@ export class State { } else { this.starredClasses.add(cls.id); } - this.store.set("starredClasses", Array.from(this.starredClasses)); + const starredArray = Array.from(this.starredClasses); + + this.store.globalSet("starredClasses", starredArray); + if (this.accessToken) + void setFavoriteCourses(this.accessToken, starredArray); this.updateState(); } @@ -585,9 +592,56 @@ export class State { } } // Load starred classes from storage - const storedStarred = this.store.get("starredClasses"); - if (storedStarred) { - this.starredClasses = new Set(storedStarred); + const storedStarred = this.store.globalGet("starredClasses") ?? []; + + // backwards compatibility, change from term store to global store + const storedStarredTerm = this.store.get("starredClasses") as + | string[] + | null; + if (storedStarredTerm) { + const totalStarred = storedStarred.concat(storedStarredTerm); + this.store.globalSet("starredClasses", totalStarred); + + storedStarredTerm.forEach((cls) => { + if (!(cls in storedStarred)) { + storedStarred.push(cls); + } + }); + + this.store.delete("starredClasses"); } + + this.starredClasses = new Set(storedStarred); + } + + loadAccessToken(token?: string): void { + if (token === this.accessToken) { + return; // no need to update anything + } + + // token has changed! + if (token) { + // user signed in + getFavoriteCourses(token) + .then((favoriteCourses) => { + favoriteCourses.forEach((cls) => { + if (!this.starredClasses.has(cls)) { + this.starredClasses.add(cls); + } + }); + + this.store.globalSet( + "starredClasses", + Array.from(this.starredClasses), + ); + }) + .catch((err: unknown) => { + console.error("Failed to fetch favorite courses:", err); + }); + } else { + // TODO: user signed out + } + + this.accessToken = token; } } diff --git a/src/lib/store.ts b/src/lib/store.ts index 25dfd175..3d50ba20 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -3,12 +3,12 @@ import type { Preferences, Save } from "./schema"; export interface TermStore { saves: Save[]; /** Array of class numbers that are starred */ - starredClasses: string[]; [saveId: string]: unknown[]; } export interface GlobalStore { preferences: Preferences; + starredClasses: string[]; } /** Generic storage. */ @@ -49,4 +49,12 @@ export class Store { globalSet(key: T, value: GlobalStore[T]): void { localStorage.setItem(this.toKey(key, true), JSON.stringify(value)); } + + delete(key: keyof TermStore): void { + localStorage.removeItem(this.toKey(key.toString(), false)); + } + + globalDelete(key: keyof GlobalStore): void { + localStorage.removeItem(this.toKey(key, true)); + } } diff --git a/src/root.tsx b/src/root.tsx index 02a2f74d..f983545d 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -15,6 +15,12 @@ import { Provider } from "./components/ui/provider"; import { Flex, Spinner, Text, Stack, Code } from "@chakra-ui/react"; import "@fontsource-variable/inter/index.css"; +import { + destroySession, + FIREROAD_VERIFY_URL, + getSession, + SessionContext, +} from "./lib/auth"; // eslint-disable-next-line react-refresh/only-export-components export const links: Route.LinksFunction = () => [ @@ -79,8 +85,35 @@ export const Layout = withEmotionCache((props: LayoutProps, cache) => { ); }); -export default function Root() { - return ; +// eslint-disable-next-line react-refresh/only-export-components +export async function clientLoader() { + const session = await getSession(document.cookie); + + if (session.has("access_token")) { + const response = await fetch(FIREROAD_VERIFY_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${session.get("access_token") ?? ""}`, + }, + }); + + if (!response.ok) { + // token expired + console.log("Token expired!"); + document.cookie = await destroySession(session); + return { session: null }; + } + } + + return { session }; +} + +export default function Root({ loaderData }: Route.ComponentProps) { + return ( + + + + ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { diff --git a/src/routes/_index.tsx b/src/routes/_index.tsx index 3100f110..f5d1e7ae 100644 --- a/src/routes/_index.tsx +++ b/src/routes/_index.tsx @@ -1,4 +1,7 @@ +import { useContext, useMemo } from "react"; + import { Center, Flex, Group, ButtonGroup } from "@chakra-ui/react"; +import { ActivityDescription } from "../components/ActivityDescription"; import { Calendar } from "../components/Calendar"; import { LeftFooter } from "../components/Footers"; import { Header, PreferencesDialog } from "../components/Header"; @@ -7,6 +10,7 @@ import { ScheduleOption } from "../components/ScheduleOption"; import { ScheduleSwitcher } from "../components/ScheduleSwitcher"; import { TermSwitcher } from "../components/TermSwitcher"; import { Banner } from "../components/Banner"; +import { AuthButton } from "../components/Auth"; import { MatrixLink, PreregLink, @@ -19,9 +23,9 @@ import { Term } from "../lib/dates"; import { type SemesterData, getStateMaps } from "../lib/hydrant"; import { useHydrant, HydrantContext, fetchNoCache } from "../lib/hydrant"; import { getClosestUrlName, type LatestTermInfo } from "../lib/dates"; +import { SessionContext } from "../lib/auth"; import type { Route } from "./+types/_index"; -import { ActivityDescription } from "~/components/ActivityDescription"; // eslint-disable-next-line react-refresh/only-export-components export async function clientLoader({ request }: Route.ClientLoaderArgs) { @@ -75,6 +79,8 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { }; } +clientLoader.hydrate = true as const; + /** The application entry. */ function HydrantApp() { return ( @@ -91,10 +97,13 @@ function HydrantApp() {
- + - + + + +
@@ -125,8 +134,13 @@ export const meta: Route.MetaFunction = () => [ /** The main application. */ export default function App({ loaderData }: Route.ComponentProps) { const { globalState } = loaderData; + const session = useContext(SessionContext); const hydrantData = useHydrant({ globalState }); + useMemo(() => { + hydrantData.state.loadAccessToken(session?.get("access_token")); + }, [session, hydrantData.state]); + return ( diff --git a/src/routes/auth.callback.ts b/src/routes/auth.callback.ts new file mode 100644 index 00000000..e71821e5 --- /dev/null +++ b/src/routes/auth.callback.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/only-throw-error */ +import { data, replace } from "react-router"; + +import type { SessionData } from "../lib/auth"; +import { + commitSession, + FIREROAD_FETCH_TOKEN_URL, + getSession, +} from "../lib/auth"; +import type { Route } from "./+types/auth.callback"; + +export async function clientLoader({ request }: Route.ClientLoaderArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const next = url.searchParams.get("next"); + + if (!code) { + throw data(null, 400); + } + + const fetch_token_url = new URL(FIREROAD_FETCH_TOKEN_URL); + fetch_token_url.searchParams.set("code", code); + + try { + const response = await fetch(fetch_token_url.toString()); + + if (!response.ok) { + // error fetching token + throw data(null, 400); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const access_info = (await response.json()).access_info as SessionData; + + const session = await getSession(document.cookie); + + session.set("academic_id", access_info.academic_id); + session.set("access_token", access_info.access_token); + session.set("current_semester", access_info.current_semester); + session.set("success", access_info.success); + session.set("username", access_info.username); + console.log("Session data set", session.data); + + document.cookie = await commitSession(session); + + return replace(next ?? "/"); + } catch { + throw data(null, 500); + } +} + +clientLoader.hydrate = true as const; diff --git a/src/routes/auth.login.ts b/src/routes/auth.login.ts new file mode 100644 index 00000000..ffa5fc39 --- /dev/null +++ b/src/routes/auth.login.ts @@ -0,0 +1,20 @@ +import { redirect } from "react-router"; +import { FIREROAD_LOGIN_URL } from "../lib/auth"; + +import type { Route } from "./+types/auth.login"; + +export function clientLoader({ request }: Route.ClientLoaderArgs) { + const authorizationURL = new URL(FIREROAD_LOGIN_URL); + + const currentUrl = new URL(request.url); + const callbackUrl = new URL( + "/auth/callback" + currentUrl.search, + currentUrl.origin, + ); + + authorizationURL.searchParams.set("redirect", callbackUrl.toString()); + + return redirect(authorizationURL.toString()); +} + +clientLoader.hydrate = true as const; diff --git a/src/routes/auth.logout.ts b/src/routes/auth.logout.ts new file mode 100644 index 00000000..692b8223 --- /dev/null +++ b/src/routes/auth.logout.ts @@ -0,0 +1,16 @@ +import { replace } from "react-router"; + +import { destroySession, getSession } from "../lib/auth"; +import type { Route } from "./+types/auth.callback"; + +export async function clientLoader({ request }: Route.ClientLoaderArgs) { + const url = new URL(request.url); + const next = url.searchParams.get("next"); + + const session = await getSession(document.cookie); + document.cookie = await destroySession(session); + + return replace(next ?? "/"); +} + +clientLoader.hydrate = true as const; diff --git a/src/routes/export.ts b/src/routes/export.ts index e8e70d8d..47e0b0b2 100644 --- a/src/routes/export.ts +++ b/src/routes/export.ts @@ -66,3 +66,5 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const filledCallback = `${callback}?hydrant=true${encodedClasses}`; return redirect(filledCallback); } + +clientLoader.hydrate = true as const; diff --git a/src/routes/overrides.($prefillId).tsx b/src/routes/overrides.($prefillId).tsx index 6185424b..a28db165 100644 --- a/src/routes/overrides.($prefillId).tsx +++ b/src/routes/overrides.($prefillId).tsx @@ -107,6 +107,8 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { return { prefillData, prefillId }; } +clientLoader.hydrate = true as const; + /** The main application. */ export default function App({ loaderData }: Route.ComponentProps) { const { prefillData, prefillId } = loaderData;