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;