Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2d72d1d
add oauth basics
dtemkin1 Jun 8, 2025
86ea7fe
format...
dtemkin1 Jun 8, 2025
831acf8
add session context
dtemkin1 Jun 8, 2025
a935cf0
add helpers for favorite courses
dtemkin1 Jun 8, 2025
1ef6590
make starred classes global
dtemkin1 Jun 8, 2025
80babc7
add getting favorite courses
dtemkin1 Jun 8, 2025
d3a850d
add more helper functions
dtemkin1 Jun 8, 2025
131081e
sync favorites
dtemkin1 Jun 8, 2025
7627e9c
fixes
dtemkin1 Jun 9, 2025
a991b20
use file base routing for simplicity...
dtemkin1 Jun 9, 2025
fa8326a
nicer auth button
dtemkin1 Jun 9, 2025
696b90b
clean up, add next search param (pending server fix)
dtemkin1 Jun 9, 2025
5731a9c
format...
dtemkin1 Jun 9, 2025
bed8276
use url correctly...
dtemkin1 Jun 9, 2025
e7dd43a
Merge remote-tracking branch 'upstream/main' into auth
dtemkin1 Jun 10, 2025
89e8388
Merge remote-tracking branch 'upstream' into auth
dtemkin1 Jun 13, 2025
0bc4e14
run client loader during hydration
dtemkin1 Jun 13, 2025
f36cc72
bump packages
dtemkin1 Jun 13, 2025
ad4796d
stop chrome 404
dtemkin1 Jun 13, 2025
1a34310
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Nov 5, 2025
27f1c30
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Nov 19, 2025
5589a78
format
dtemkin1 Nov 19, 2025
4964a67
add color
dtemkin1 Nov 23, 2025
5945e89
add sync schedule function
dtemkin1 Nov 23, 2025
ad40cc3
add results from server code
dtemkin1 Nov 23, 2025
fa34bfb
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Dec 3, 2025
288edda
format...
dtemkin1 Dec 3, 2025
6d31dba
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Dec 7, 2025
3c7b4be
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Dec 7, 2025
2a9813c
undo this
dtemkin1 Dec 8, 2025
af77be0
only add ?next if not index
dtemkin1 Dec 8, 2025
d89335d
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Dec 9, 2025
e2b55c7
Merge branch 'main' of https://github.com/sipb/hydrant into auth
dtemkin1 Jan 4, 2026
a2255d0
undo not necessary
dtemkin1 Jan 4, 2026
18d2a6c
nvmd
dtemkin1 Jan 4, 2026
662f8a4
move types around
dtemkin1 Jan 4, 2026
b301bc7
security
dtemkin1 Jan 4, 2026
1a96ef7
consistency
dtemkin1 Jan 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/components/Auth.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip content={tooltipContent}>
<IconButton
aria-label={label}
size="sm"
variant="outline"
colorPalette={color}
asChild
>
<Link
to={{
pathname,
search:
location.pathname != "/"
? `?next=${location.pathname}`
: undefined,
}}
>
<UserIcon />
</Link>
</IconButton>
</Tooltip>
);
}
271 changes: 271 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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<SessionData, SessionFlashData>({
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<Awaited<
ReturnType<typeof getSession>
> | 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<string, { name: string; changed: string; agent: string }>;
};

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;
};
Loading