Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app/api/tutor/live/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
role: "user",
parts: [{ text: systemPrompt }],
},
...history.map((msg: any) => ({
...history.map((msg: { role: string; content: string }) => ({
role: msg.role === "ai" ? "model" : "user",
parts: [{ text: msg.content }],
})),
Comment on lines +64 to 67
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate and normalize history before mapping roles

Line 64’s inline type is compile-time only; history is still untrusted JSON at runtime. Also, Line 65 currently maps any non-"ai" role to "user", which misclassifies "system"/invalid roles. Please sanitize first, then map only allowed roles.

Proposed fix
+import type { ChatMessage } from "@/lib/types";
+
+function normalizeHistory(input: unknown): ChatMessage[] {
+  if (!Array.isArray(input)) return [];
+  return input
+    .filter((m): m is ChatMessage =>
+      !!m &&
+      typeof m === "object" &&
+      typeof (m as { role?: unknown }).role === "string" &&
+      typeof (m as { content?: unknown }).content === "string" &&
+      ["user", "ai", "system"].includes((m as { role: string }).role)
+    );
+}
...
-const history = formData.get("history") ? JSON.parse(formData.get("history") as string) : [];
+const rawHistory = formData.get("history");
+const history = rawHistory ? normalizeHistory(JSON.parse(rawHistory as string)) : [];
...
-...history.map((msg: { role: string; content: string }) => ({
-    role: msg.role === "ai" ? "model" : "user",
+...history.map((msg) => ({
+    role: msg.role === "ai" ? "model" : msg.role === "user" ? "user" : "user",
     parts: [{ text: msg.content }],
 })),
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/tutor/live/route.ts` around lines 64 - 67, history is untrusted at
runtime and the current inline mapping treats any non-"ai" role as "user";
validate that history is an array and each entry is an object with string role
and content, filter out invalid or empty items, normalize allowed roles only
(map "ai" -> "model", leave "user" and "system" as-is), and drop or log entries
with other roles; then use the validated list in the existing mapping code
(reference the history variable and the mapping block that produces { role: ...,
parts: [{ text: ... }] }) so only sanitized, normalized roles and string
contents are emitted.

Expand Down
10 changes: 7 additions & 3 deletions app/courses/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Link from "next/link"
export default function CreateCourse() {
const { data: session, status } = useSession()
const router = useRouter()
const [isLoading, setIsLoading] = useState(true)
const [mounted, setMounted] = useState(false)
const [courseName, setCourseName] = useState("")
const [subject, setSubject] = useState("")
const [description, setDescription] = useState("")
Expand All @@ -22,12 +22,16 @@ export default function CreateCourse() {
const email = session?.user?.email;
if (email && !isOnboardingComplete(email)) {
router.push("/onboarding")
} else {
setIsLoading(false)
}
}
}, [status, session, router])

useEffect(() => {
setMounted(true)
}, [])

const isLoading = !mounted || status === "loading"

Comment on lines +29 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Protected ui renders pre-redirect 🐞 Bug ⛨ Security

CreateCourse (and the same pattern in Schedule/Dashboard/MyQuizzes) now sets `isLoading = !mounted
|| status === "loading", so after mounted` flips true the page can render its main UI even when
status === "unauthenticated" or onboarding is incomplete. Because the redirect happens in
useEffect (post-paint), users can briefly see and interact with protected pages before navigation
occurs.
Agent Prompt
### Issue description
Several pages redirect to `/login` or `/onboarding` inside `useEffect`, but their `isLoading` no longer accounts for those redirect conditions. After `mounted` becomes `true`, the main UI can render for at least one paint before the redirect effect runs.

### Issue Context
This was introduced by replacing explicit `isLoading` state updates with a `mounted` pattern. The redirect logic still happens in effects, so rendering must remain blocked while a redirect decision is pending.

### Fix Focus Areas
- app/courses/create/page.tsx[18-44]
- app/schedule/page.tsx[15-33]
- app/dashboard/page.tsx[69-112]
- app/quizzes/page.tsx[20-66]

### What to change
- Compute a `shouldRedirectToLogin` / `shouldRedirectToOnboarding` boolean (guarded by `mounted`), and include it in the render gate.
- Keep rendering the loading screen (or return `null`) while `shouldRedirect*` is true, so protected UI never renders before navigation.

Example pattern:
```ts
const email = session?.user?.email
const onboardingIncomplete = mounted && status === "authenticated" && email && !isOnboardingComplete(email)
const unauthenticated = mounted && status === "unauthenticated"
const isLoading = !mounted || status === "loading" || unauthenticated || onboardingIncomplete

useEffect(() => {
  if (status === "unauthenticated") router.push("/login")
  else if (status === "authenticated" && email && !isOnboardingComplete(email)) router.push("/onboarding")
}, [status, email, router])
```

β“˜ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// In a real app, this would save to a database
Expand Down
28 changes: 12 additions & 16 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,11 @@ export default function Dashboard() {
const { data: session, status } = useSession()
const router = useRouter()
const { t } = useLanguage()
const [userStats, setUserStats] = useState<UserStats>(defaultStats)
const [userData, setUserData] = useState<UserData>(emptyUserData)
const [isLoading, setIsLoading] = useState(true)
const [isMounted, setIsMounted] = useState(false)
const [userData] = useState<UserData>(emptyUserData)
const [showTutorial, setShowTutorial] = useState(false)
const [activityPeriod, setActivityPeriod] = useState("week")
const { theme, toggleTheme, mounted } = useTheme()
const { theme, toggleTheme, mounted: themeMounted } = useTheme()

// Check authentication and onboarding
useEffect(() => {
Expand All @@ -85,24 +84,21 @@ export default function Dashboard() {
// Record today's activity
recordActivity()

// Load user stats
const profile = getUserProfile()
if (profile?.stats) {
setUserStats(profile.stats)
}


// Check if tutorial should be shown
if (email && !isTutorialComplete(email)) {
setShowTutorial(true)
}

// Load user data (empty for new users)
setUserData(emptyUserData)
setIsLoading(false)
}
}, [status, session, router])

useEffect(() => {
setIsMounted(true)
}, [])

const profile = isMounted ? getUserProfile() : null
const userStats = profile?.stats || defaultStats
const isLoading = !isMounted || status === "loading"

// Get display name from session or profile
const displayName = session?.user?.name?.split(" ")[0] || "Learner"

Expand Down Expand Up @@ -170,7 +166,7 @@ export default function Dashboard() {
title={theme === 'dark' ? "Switch to Light Mode" : "Switch to Dark Mode"}
aria-label={theme === 'dark' ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
<span className={`material-symbols-outlined ${!mounted ? 'invisible' : ''}`}>
<span className={`material-symbols-outlined ${!themeMounted ? 'invisible' : ''}`}>
{theme === 'dark' ? 'light_mode' : 'dark_mode'}
</span>
</button>
Expand Down
14 changes: 11 additions & 3 deletions app/quizzes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function MyQuizzes() {
const router = useRouter()
const { t } = useLanguage()
const [quizzes, setQuizzes] = useState<SavedQuiz[]>([])
const [isLoading, setIsLoading] = useState(true)
const [mounted, setMounted] = useState(false)

useEffect(() => {
if (status === "unauthenticated") {
Expand All @@ -30,11 +30,19 @@ export default function MyQuizzes() {
return
}

setQuizzes(getUserQuizzes())
setIsLoading(false)
// To completely avoid warnings and still load from local storage:
const loadedQuizzes = getUserQuizzes()
setQuizzes(loadedQuizzes)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, session, router])

useEffect(() => {
setMounted(true)
}, [])

const isLoading = !mounted || status === "loading"

const handleDelete = (id: string) => {
if (confirm(t("quizzes.delete_confirm"))) {
deleteQuiz(id)
Expand Down
10 changes: 7 additions & 3 deletions app/schedule/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { isOnboardingComplete } from "@/lib/userStore"
export default function Schedule() {
const { data: session, status } = useSession()
const router = useRouter()
const [isLoading, setIsLoading] = useState(true)
const [mounted, setMounted] = useState(false)

useEffect(() => {
if (status === "unauthenticated") {
Expand All @@ -19,12 +19,16 @@ export default function Schedule() {
const email = session?.user?.email;
if (email && !isOnboardingComplete(email)) {
router.push("/onboarding")
} else {
setIsLoading(false)
}
}
}, [status, session, router])

useEffect(() => {
setMounted(true)
}, [])

const isLoading = !mounted || status === "loading"

if (status === "loading" || isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-background-dark">
Expand Down
9 changes: 5 additions & 4 deletions components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Initialize theme from localStorage or system preference
useEffect(() => {
const savedTheme = localStorage.getItem("theme") as Theme | null
let initialTheme: Theme = "light"
if (savedTheme) {
setThemeState(savedTheme)
initialTheme = savedTheme
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setThemeState("dark")
} else {
setThemeState("light")
initialTheme = "dark"
}

setThemeState(initialTheme)
Comment on lines 22 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor

Validate the persisted theme before treating it as Theme.

localStorage is untyped input. The cast on Line 22 allows any string to flow into theme, and Line 41 later adds that value as a class name. If "theme" is corrupted, the app can end up with neither light nor dark applied.

πŸ›‘οΈ Proposed fix
-        const savedTheme = localStorage.getItem("theme") as Theme | null
+        const savedTheme = localStorage.getItem("theme")
         let initialTheme: Theme = "light"
-        if (savedTheme) {
+        if (savedTheme === "light" || savedTheme === "dark") {
             initialTheme = savedTheme
         } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
             initialTheme = "dark"
         }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const savedTheme = localStorage.getItem("theme") as Theme | null
let initialTheme: Theme = "light"
if (savedTheme) {
setThemeState(savedTheme)
initialTheme = savedTheme
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setThemeState("dark")
} else {
setThemeState("light")
initialTheme = "dark"
}
setThemeState(initialTheme)
const savedTheme = localStorage.getItem("theme")
let initialTheme: Theme = "light"
if (savedTheme === "light" || savedTheme === "dark") {
initialTheme = savedTheme
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
initialTheme = "dark"
}
setThemeState(initialTheme)
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/ThemeProvider.tsx` around lines 22 - 30, Validate the value
returned from localStorage before treating it as a Theme: don’t cast savedTheme
to Theme directlyβ€”check that savedTheme equals one of the allowed values (e.g.,
"light" or "dark") before assigning it to initialTheme; if it’s invalid or
missing, fall back to window.matchMedia("(prefers-color-scheme: dark)") and then
to the default "light". Update the logic around savedTheme, initialTheme, and
setThemeState (or add a small isValidTheme helper) so only known Theme values
are ever passed into setThemeState and used as class names.

setMounted(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
Expand Down
2 changes: 1 addition & 1 deletion lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"

// @ts-ignore
// @ts-expect-error Types may be incomplete or global variables need mocking
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
Expand Down
2 changes: 1 addition & 1 deletion lib/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('security lib', () => {

// Passing something that isn't a string might trigger an error if not handled
// although verifyData expects a string type.
// @ts-ignore
// @ts-expect-error Types may be incomplete or global variables need mocking
const isValid = verifyData(testData, { not: 'a string' });
Comment on lines +66 to 67
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n lib/security.test.ts | head -100

Repository: APPLEPIE6969/StudyFlow

Length of output: 3287


🏁 Script executed:

cat -n lib/security.ts | head -50

Repository: APPLEPIE6969/StudyFlow

Length of output: 1703


Update the @ts-expect-error comment to reflect the actual test intent.

The current comment references "global variables need mocking" which is unrelated to this test. The test intentionally passes an object instead of the expected string to verify error handling.

✏️ Suggested edit
-      // `@ts-expect-error` Types may be incomplete or global variables need mocking
+      // `@ts-expect-error` Intentional type mismatch: verifyData expects signature as string
       const isValid = verifyData(testData, { not: 'a string' });
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// @ts-expect-error Types may be incomplete or global variables need mocking
const isValid = verifyData(testData, { not: 'a string' });
// `@ts-expect-error` Intentional type mismatch: verifyData expects signature as string
const isValid = verifyData(testData, { not: 'a string' });
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/security.test.ts` around lines 66 - 67, Update the misleading TypeScript
suppression comment above the failing call to verifyData so it reflects the
actual test intent: instead of mentioning "global variables need mocking",
replace the `@ts-expect-error` message to indicate that this test deliberately
passes a non-string (an object) as the second argument to verifyData to exercise
error handling with invalid input (e.g., "@ts-expect-error passing object
instead of string to verifyData"). Ensure the comment is adjacent to the
verifyData(testData, { not: 'a string' }) call so reviewers understand the
intentional type mismatch.

assert.strictEqual(isValid, false);
});
Expand Down
4 changes: 2 additions & 2 deletions lib/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ export class MockStorage {

export function setupTests() {
if (typeof global.window === "undefined") {
// @ts-ignore
// @ts-expect-error Types may be incomplete or global variables need mocking
global.window = {};
}

if (typeof global.localStorage === "undefined") {
// @ts-ignore
// @ts-expect-error Types may be incomplete or global variables need mocking
global.localStorage = new MockStorage();
}
}
Expand Down