Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
853b69a
chore(env): update env example
4rthurCai Dec 8, 2025
dfd9a95
chore(package): update package json
4rthurCai Dec 8, 2025
f95b6da
fix: update vue scripts to adapt backend and fix requests
4rthurCai Dec 8, 2025
58f9f6b
fix: update jsons for api usage
4rthurCai Dec 8, 2025
c5715e2
fix: calculate course list total_pages, rename filter param num_revie…
A-lexisL Jan 20, 2026
b05eb18
fix: change fetch to apiFetch for auth apis
A-lexisL Jan 21, 2026
5b355d5
refactor: move auth api wrappers from utils/ to composables/useAuth.js
A-lexisL Jan 21, 2026
5646dd6
refactor: mv api call of landing to src/composables/useLanding.js
A-lexisL Jan 21, 2026
3bddc17
refactor: mv api call of coursedetail to src/composables/useCourses.js
A-lexisL Jan 21, 2026
5e22464
refactor: mv api call of course review search to src/composables/useR…
A-lexisL Jan 21, 2026
774f01a
Merge remote-tracking branch 'origin/dev' into fix/fetch
A-lexisL Jan 21, 2026
9fea7c1
fix: correctly handles POST review response(temp solution)
A-lexisL Jan 21, 2026
4c5142d
chore: rm unused dev dep
A-lexisL Jan 21, 2026
86eb459
fix: make copilot happy
A-lexisL Jan 21, 2026
a493f33
fix: add consistent pl-3
A-lexisL Jan 21, 2026
a313364
fix: frontend handles CourseDetail updates instead of refetching backend
A-lexisL Jan 22, 2026
9b97ac5
fix: rm duplicate fetchReview on mount
A-lexisL Jan 22, 2026
50038f0
refactor: rm custom event, make isAuthenticated global status(rw in u…
A-lexisL Jan 22, 2026
74d8e0b
Merge branch 'dev' into fix/fetch
A-lexisL Jan 22, 2026
24c7199
fix(auth): Clear `localStorage` states when logged in
PACHAKUTlQ Jan 27, 2026
047c379
fix(review): Fix initial auth error at review fetch
PACHAKUTlQ Jan 27, 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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# the env variable used for vite-processed frontend should begin with `VITE_`
VITE_TURNSTILE_SITE_KEY=0x4AAAAAABz2ci0ZN9OaO-dg
VITE_AUTH_OTP_TIMEOUT=120
VITE_AUTH_TEMP_TOKEN_TIMEOUT=600
VITE_AUTH_TEMP_TOKEN_TIMEOUT=600
VITE_API_BASE_URL=
32 changes: 16 additions & 16 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/components/AuthInitiate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,10 @@

<script setup>
import { ref, onMounted, computed } from "vue";
import { initiateAuth, getOtpState } from "../utils/auth";
import { getOtpState } from "../utils/auth";
import Turnstile from "./Turnstile.vue";
import { ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
import { useAuth } from "../composables/useAuth";

const props = defineProps({
action: {
Expand All @@ -130,6 +131,8 @@ const copyButtonText = ref("Copy Code and Proceed");
const isRedirecting = ref(false);
const isLoading = ref(false);

const { initiateAuth } = useAuth();

onMounted(() => {
const existingOtp = getOtpState();
if (existingOtp) {
Expand Down
6 changes: 3 additions & 3 deletions src/components/CourseList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
v-model.number="filters.min_quality"
type="number"
min="0"
class="mt-1 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
class="mt-1 block w-full rounded-md border-0 py-1.5 pl-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
Comment thread
A-lexisL marked this conversation as resolved.
@change="applyFiltersAndSort"
/>
</div>
Expand All @@ -88,12 +88,12 @@
@change="applyFiltersAndSort"
>
<option value="course_code">Course Code</option>
<option value="num_reviews">Number of Reviews</option>
<option value="review_count">Number of Reviews</option>
Comment thread
A-lexisL marked this conversation as resolved.
<option v-if="isAuthenticated" value="quality_score">
Quality Score
</option>
<option v-if="isAuthenticated" value="difficulty_score">
Difficulty (Layup) Score
Difficulty Score
Comment thread
A-lexisL marked this conversation as resolved.
</option>
</select>
</div>
Expand Down
1 change: 0 additions & 1 deletion src/components/ReviewCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ const handleVote = async (reviewId, isKudos) => {
user_vote: data.user_vote,
});
} catch (e) {
console.error("Error voting on review:", e);
alert("Error voting on review. Please try again.");
}
};
Expand Down
3 changes: 1 addition & 2 deletions src/components/SetPasswordForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@
<script setup>
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { setPassword } from "../utils/auth";
import PasswordInput from "./PasswordInput.vue";
import {
validatePassword,
Expand All @@ -145,7 +144,7 @@ const props = defineProps({
});

const router = useRouter();
const { notifyAuthStateChanged } = useAuth();
const { notifyAuthStateChanged, setPassword } = useAuth();

const password = ref("");
const confirmPassword = ref("");
Expand Down
155 changes: 151 additions & 4 deletions src/composables/useAuth.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
import { ref, onMounted, onUnmounted } from "vue";
import { checkAuthentication as checkAuthUtil } from "../utils/api";
import { apiFetch } from "../utils/api";
import { getCookie } from "../utils/cookies";
import {
OTP_STORAGE_KEY,
FLOW_STATE_STORAGE_KEY,
clearAuthFlowState,
} from "../utils/auth";

// Default timeout
const DEFAULT_OTP_TIMEOUT_SECONDS = 120;
const DEFAULT_TEMP_TOKEN_TIMEOUT_SECONDS = 600;

function parsePositiveInt(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.floor(parsed);
}

function getOtpTimeoutSeconds() {
return parsePositiveInt(
import.meta.env.VITE_AUTH_OTP_TIMEOUT,
DEFAULT_OTP_TIMEOUT_SECONDS,
);
}

function getTempTokenTimeoutSeconds() {
return parsePositiveInt(
import.meta.env.VITE_AUTH_TEMP_TOKEN_TIMEOUT,
DEFAULT_TEMP_TOKEN_TIMEOUT_SECONDS,
);
}

export function useAuth() {
const isAuthenticated = ref(false);

const checkAuthentication = async () => {
try {
const auth = await checkAuthUtil();
isAuthenticated.value = !!auth;
const response = await apiFetch("/api/user/status/");
if (response.ok) {
const data = await response.json();
isAuthenticated.value = !!data.isAuthenticated;
return isAuthenticated.value;
}
isAuthenticated.value = false;
return isAuthenticated.value;
} catch (e) {
console.error("useAuth: checkAuthentication error:", e);
Expand All @@ -17,9 +51,118 @@ export function useAuth() {
}
};

const initiateAuth = async (action, turnstileToken) => {
const response = await apiFetch("/api/auth/init/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({
action,
turnstile_token: turnstileToken,
}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "Failed to initiate auth flow.");
}

const data = await response.json();
const now = Date.now();
const otpExpiresAt = now + getOtpTimeoutSeconds() * 1000;
const tempTokenExpiresAt = now + getTempTokenTimeoutSeconds() * 1000;

localStorage.setItem(
OTP_STORAGE_KEY,
JSON.stringify({ otp: data.otp, expires_at: otpExpiresAt }),
);
localStorage.setItem(
FLOW_STATE_STORAGE_KEY,
JSON.stringify({ status: "pending", expires_at: tempTokenExpiresAt }),
);

return { otp: data.otp, redirectUrl: data.redirect_url };
};

const verifyCallback = async (action, account, answerId) => {
const response = await apiFetch("/api/auth/verify/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({
action,
account,
answer_id: answerId,
}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "Failed to verify authentication.");
}

const data = await response.json();
localStorage.setItem(
FLOW_STATE_STORAGE_KEY,
JSON.stringify({
status: "verified",
action: data.action,
expires_at: data.expires_at * 1000,
}),
);

return data;
};

const setPassword = async (action, password) => {
const url =
action === "signup" ? "/api/auth/signup/" : "/api/auth/password/";
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({ password }),
});

if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "Failed to set password.");
}

clearAuthFlowState();
return await response.json();
};

const loginWithPassword = async (account, password, turnstileToken) => {
const response = await apiFetch("/api/auth/login/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({
account,
password,
turnstile_token: turnstileToken,
}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "Login failed.");
}
return await response.json();
};

const logout = async () => {
try {
const response = await fetch("/api/auth/logout/", {
const response = await apiFetch("/api/auth/logout/", {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down Expand Up @@ -61,6 +204,10 @@ export function useAuth() {
return {
isAuthenticated,
checkAuthentication,
initiateAuth,
verifyCallback,
setPassword,
loginWithPassword,
logout,
notifyAuthStateChanged,
};
Expand Down
42 changes: 32 additions & 10 deletions src/composables/useCourses.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ref, reactive } from "vue";
import { apiFetch } from "../utils/api";

export function useCourses() {
const courses = ref([]);
Expand All @@ -10,7 +11,6 @@ export function useCourses() {
current_page: 1,
total_pages: 1,
total_courses: 0,
limit: 20,
});

const filters = reactive({
Expand All @@ -27,11 +27,11 @@ export function useCourses() {

const fetchDepartments = async () => {
try {
const response = await fetch("/api/departments/");
const response = await apiFetch("/api/departments/");
if (!response.ok) throw new Error("Failed to fetch departments");
departments.value = await response.json();
} catch (e) {
console.error("useCourses: Error fetching departments:", e);
error.value = e.message;
}
};

Expand All @@ -44,12 +44,15 @@ export function useCourses() {
if (filters.code) params.append("code", filters.code.trim());
if (filters.min_quality && isAuth)
params.append("min_quality", filters.min_quality);
if (filters.min_difficulty && isAuth)
params.append("min_difficulty", filters.min_difficulty);
Comment thread
A-lexisL marked this conversation as resolved.

params.append("sort_by", sorting.sort_by);
params.append("sort_order", sorting.sort_order);
params.append("page", pagination.current_page);

try {
const response = await fetch(`/api/courses/?${params.toString()}`);
const response = await apiFetch(`/api/courses/?${params.toString()}`);
if (!response.ok) {
const errorData = await response
.json()
Expand All @@ -59,20 +62,38 @@ export function useCourses() {
);
}
const data = await response.json();
courses.value = data.courses;
pagination.current_page = data.pagination.current_page;
pagination.total_pages = data.pagination.total_pages;
pagination.total_courses = data.pagination.total_courses;
pagination.limit = data.pagination.limit;
// DRF pagination: { count, next, previous, results }
courses.value = data.results || [];
const totalCount = data.count || 0;
pagination.total_courses = totalCount;
// TODO: let backend return total pages
if (pagination.current_page == 1) {
Comment thread
A-lexisL marked this conversation as resolved.
const page_size = courses.value.length;
pagination.total_pages =
page_size > 0 ? Math.ceil(totalCount / page_size) : 1;
}
Comment on lines +70 to +74
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total_pages calculation logic is flawed. It only recalculates when current_page is 1, meaning if a user directly navigates to page 2 or higher, total_pages won't be updated. Additionally, the calculation assumes all pages have the same number of results as page 1, which may not be true for the last page. Consider using a fixed page size or having the backend return total_pages.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchCourses is called onMount while current_page is initialized as 1. So to me this is acceptable.
However, a more desired implementation is to let backend return total_pages

} catch (e) {
console.error("useCourses: Error fetching courses:", e);
error.value = e.message;
courses.value = [];
} finally {
loading.value = false;
}
};

const fetchCourse = async (courseId) => {
if (!courseId) return null;
try {
const response = await apiFetch(`/api/courses/${courseId}/`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (e) {
error.value = e.message;
throw e;
}
};

const getQueryObject = (isAuth = false) => {
const query = {};
if (filters.department) query.department = filters.department;
Expand Down Expand Up @@ -125,6 +146,7 @@ export function useCourses() {
pagination,
filters,
sorting,
fetchCourse,
fetchDepartments,
fetchCourses,
getQueryObject,
Expand Down
Loading
Loading