Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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=
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"
@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>
<option v-if="isAuthenticated" value="quality_score">
Quality Score
</option>
<option v-if="isAuthenticated" value="difficulty_score">
Difficulty (Layup) Score
Difficulty Score
</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
4 changes: 1 addition & 3 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 { setPassword } = useAuth();

const password = ref("");
const confirmPassword = ref("");
Expand Down Expand Up @@ -209,7 +208,6 @@ const handleSubmit = async () => {

try {
await setPassword(props.action, password.value);
notifyAuthStateChanged();
router.push("/");
} catch (e) {
submitError.value = e.message;
Expand Down
191 changes: 164 additions & 27 deletions src/composables/useAuth.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,49 @@
import { ref, onMounted, onUnmounted } from "vue";
import { checkAuthentication as checkAuthUtil } from "../utils/api";
import { ref } from "vue";
import { apiFetch } from "../utils/api";
import { getCookie } from "../utils/cookies";
import {
OTP_STORAGE_KEY,
FLOW_STATE_STORAGE_KEY,
clearAuthFlowState,
} from "../utils/auth";

export function useAuth() {
const isAuthenticated = ref(false);
// 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,
);
}

// Global authentication state
const isAuthenticated = ref(false);

export function useAuth() {
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 +52,127 @@ 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();

if (data.is_logged_in) {
isAuthenticated.value = true;
} else {
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.");
}

if (action === "signup") {
isAuthenticated.value = true;
}
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.");
}

isAuthenticated.value = true;
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 All @@ -28,7 +181,6 @@ export function useAuth() {
});
if (response.ok) {
isAuthenticated.value = false;
notifyAuthStateChanged();
return true;
} else {
console.error("useAuth: logout failed", response.status);
Expand All @@ -40,29 +192,14 @@ export function useAuth() {
}
};

const onAuthStateChanged = () => {
// Re-check authentication when other parts of app signal change
checkAuthentication();
};

const notifyAuthStateChanged = () => {
window.dispatchEvent(new CustomEvent("auth-state-changed"));
};

onMounted(() => {
checkAuthentication();
window.addEventListener("auth-state-changed", onAuthStateChanged);
});

onUnmounted(() => {
window.removeEventListener("auth-state-changed", onAuthStateChanged);
});

return {
isAuthenticated,
checkAuthentication,
initiateAuth,
verifyCallback,
setPassword,
loginWithPassword,
logout,
notifyAuthStateChanged,
};
}

Expand Down
Loading