diff --git a/cs17_portal/api.py b/cs17_portal/api.py
index 7930cb5..867fa07 100644
--- a/cs17_portal/api.py
+++ b/cs17_portal/api.py
@@ -18,11 +18,30 @@ def get_user_profile() -> dict | None:
return frappe.db.get_value(
"CS17 Profile",
{"user": frappe.session.user},
- ["name", "full_name", "profile_type", "cohort", "profile_picture"],
+ ["name", "full_name", "first_name", "last_name", "profile_type", "cohort", "profile_picture"],
as_dict=True,
)
+@frappe.whitelist(methods=["POST"])
+def update_my_profile(first_name: str, last_name: str, profile_picture: str = "") -> dict:
+ profile = validate_membership("Faculty")
+ doc = frappe.get_doc("CS17 Profile", profile)
+ doc.update(
+ {
+ "first_name": first_name,
+ "last_name": last_name,
+ "profile_picture": profile_picture or None,
+ }
+ )
+ doc.save(ignore_permissions=True)
+ return {
+ "name": doc.name,
+ "full_name": doc.full_name,
+ "profile_picture": doc.profile_picture,
+ }
+
+
@frappe.whitelist(methods=["GET"])
def get_faculty_assignments(cohort: str | None = None) -> list:
validate_membership("Faculty")
diff --git a/cs17_portal/www/dashboard.py b/cs17_portal/www/dashboard.py
index df3129d..113412e 100644
--- a/cs17_portal/www/dashboard.py
+++ b/cs17_portal/www/dashboard.py
@@ -32,7 +32,15 @@ def get_boot():
profiles = frappe.get_list(
"CS17 Profile",
filters={"user": current_user},
- fields=["name", "full_name", "profile_type", "cohort", "profile_picture"],
+ fields=[
+ "name",
+ "full_name",
+ "first_name",
+ "last_name",
+ "profile_type",
+ "cohort",
+ "profile_picture",
+ ],
limit=1,
ignore_permissions=True,
)
diff --git a/dashboard/index.html b/dashboard/index.html
index 7da8104..893b4a0 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -5,6 +5,12 @@
CS17 Portal
+
diff --git a/dashboard/src/faculty/FacultyProfileCard.tsx b/dashboard/src/faculty/FacultyProfileCard.tsx
new file mode 100644
index 0000000..9e7d9c4
--- /dev/null
+++ b/dashboard/src/faculty/FacultyProfileCard.tsx
@@ -0,0 +1,168 @@
+import { useRef, useState } from "react";
+import { Check, Loader2, Pencil, X } from "lucide-react";
+import { useFrappeFileUpload, useFrappePostCall } from "frappe-react-sdk";
+import {
+ updateCurrentProfile,
+ useCurrentProfile,
+} from "@/hooks/useCurrentProfile";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+const UPDATE_PROFILE = "cs17_portal.api.update_my_profile";
+
+export default function FacultyProfileCard() {
+ const { profile } = useCurrentProfile();
+ const fileInput = useRef(null);
+ const [editingName, setEditingName] = useState(false);
+ const [firstName, setFirstName] = useState(profile?.first_name ?? "");
+ const [lastName, setLastName] = useState(profile?.last_name ?? "");
+ const [error, setError] = useState(null);
+ const { upload, loading: uploading } = useFrappeFileUpload();
+ const { call, loading: saving } = useFrappePostCall(UPDATE_PROFILE);
+
+ async function save(patch: { first_name?: string; last_name?: string; profile_picture?: string }) {
+ setError(null);
+ const payload = {
+ first_name: patch.first_name ?? profile?.first_name ?? "",
+ last_name: patch.last_name ?? profile?.last_name ?? "",
+ profile_picture: patch.profile_picture ?? profile?.profile_picture ?? "",
+ };
+ try {
+ const saved = await call(payload);
+ updateCurrentProfile({ ...payload, full_name: saved.message.full_name });
+ return true;
+ } catch (err: any) {
+ setError(err?.message ?? "Could not save your profile. Please try again.");
+ return false;
+ }
+ }
+
+ async function handlePhotoChange(file: File | null) {
+ if (!file) return;
+ setError(null);
+ if (!file.type.startsWith("image/")) {
+ setError("Choose an image file.");
+ return;
+ }
+ try {
+ const uploaded = await upload(file, { isPrivate: false });
+ await save({ profile_picture: uploaded.file_url });
+ } catch (err: any) {
+ setError(err?.message ?? "Could not upload the photo. Please try again.");
+ }
+ }
+
+ function startEditingName() {
+ setFirstName(profile?.first_name ?? "");
+ setLastName(profile?.last_name ?? "");
+ setEditingName(true);
+ }
+
+ async function saveName() {
+ if (!firstName.trim() || !lastName.trim()) {
+ setError("First and last name are both required.");
+ return;
+ }
+ const saved = await save({
+ first_name: firstName.trim(),
+ last_name: lastName.trim(),
+ });
+ if (saved) setEditingName(false);
+ }
+
+ const busy = uploading || saving;
+
+ return (
+
+
+
+ {profile?.profile_picture ? (
+

+ ) : (
+
+ {profile?.full_name?.[0] ?? "?"}
+
+ )}
+
+ {profile?.profile_picture && (
+
+ )}
+ {uploading && (
+
+
+
+ )}
+
handlePhotoChange(event.target.files?.[0] ?? null)}
+ />
+
+
+ {editingName ? (
+
+ setFirstName(event.target.value)}
+ className="h-8 w-28"
+ />
+ setLastName(event.target.value)}
+ className="h-8 w-28"
+ />
+
+
+ ) : (
+
+
+
{profile?.full_name ?? "—"}
+
+
+
Faculty
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/dashboard/src/faculty/FacultySettingsPage.tsx b/dashboard/src/faculty/FacultySettingsPage.tsx
index e91516e..0d47592 100644
--- a/dashboard/src/faculty/FacultySettingsPage.tsx
+++ b/dashboard/src/faculty/FacultySettingsPage.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { useCurrentProfile } from "@/hooks/useCurrentProfile";
+import FacultyProfileCard from "@/faculty/FacultyProfileCard";
import {
Dialog,
DialogContent,
@@ -9,29 +9,10 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
-
-function getInitialTheme(): "light" | "dark" {
- try {
- const stored = localStorage.getItem("theme");
- if (stored === "dark" || stored === "light") return stored;
- } catch {}
- return document.documentElement.classList.contains("dark") ? "dark" : "light";
-}
-
-function applyTheme(theme: "light" | "dark") {
- if (theme === "dark") {
- document.documentElement.classList.add("dark");
- } else {
- document.documentElement.classList.remove("dark");
- }
- try {
- localStorage.setItem("theme", theme);
- } catch {}
-}
+import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme";
export default function FacultySettingsPage() {
- const { profile } = useCurrentProfile();
- const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
+ const [theme, setTheme] = useState(getStoredTheme);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
function toggleTheme() {
@@ -56,25 +37,7 @@ export default function FacultySettingsPage() {
Profile
-
-
- {profile?.profile_picture ? (
-

- ) : (
-
- {profile?.full_name?.[0] ?? "?"}
-
- )}
-
-
{profile?.full_name ?? "—"}
-
Faculty
-
-
-
+
diff --git a/dashboard/src/hooks/useCurrentProfile.ts b/dashboard/src/hooks/useCurrentProfile.ts
index 7000e33..ece46e8 100644
--- a/dashboard/src/hooks/useCurrentProfile.ts
+++ b/dashboard/src/hooks/useCurrentProfile.ts
@@ -1,17 +1,37 @@
+import { useSyncExternalStore } from "react";
+
interface Profile {
name: string;
full_name: string;
+ first_name: string;
+ last_name: string;
profile_type: "Student" | "Faculty";
cohort: string | null;
profile_picture: string;
}
-export function useCurrentProfile() {
- const boot = (window as any).frappe_boot;
- const profile: Profile | null = boot?.profile ?? null;
+const getBoot = () => (window as any).frappe_boot;
+
+let profile: Profile | null = getBoot()?.profile ?? null;
+const listeners = new Set<() => void>();
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function updateCurrentProfile(patch: Partial
) {
+ if (!profile) return;
+ profile = { ...profile, ...patch };
+ if (getBoot()) getBoot().profile = profile;
+ listeners.forEach((listener) => listener());
+}
+
+export function useCurrentProfile() {
return {
- profile,
+ profile: useSyncExternalStore(subscribe, () => profile),
isLoading: false,
error: null,
};
diff --git a/dashboard/src/lib/theme.ts b/dashboard/src/lib/theme.ts
new file mode 100644
index 0000000..5bac4c2
--- /dev/null
+++ b/dashboard/src/lib/theme.ts
@@ -0,0 +1,20 @@
+export type Theme = "light" | "dark";
+
+export function getStoredTheme(): Theme {
+ try {
+ const stored = localStorage.getItem("theme");
+ if (stored === "dark" || stored === "light") return stored;
+ } catch {}
+ return document.documentElement.classList.contains("dark") ? "dark" : "light";
+}
+
+export function applyTheme(theme: Theme): void {
+ setThemeClass(theme);
+ try {
+ localStorage.setItem("theme", theme);
+ } catch {}
+}
+
+function setThemeClass(theme: Theme): void {
+ document.documentElement.classList.toggle("dark", theme === "dark");
+}
diff --git a/dashboard/src/pages/SettingsPage.tsx b/dashboard/src/pages/SettingsPage.tsx
index 7db5428..29c14ae 100644
--- a/dashboard/src/pages/SettingsPage.tsx
+++ b/dashboard/src/pages/SettingsPage.tsx
@@ -9,29 +9,11 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
-
-function getInitialTheme(): "light" | "dark" {
- try {
- const stored = localStorage.getItem("theme");
- if (stored === "dark" || stored === "light") return stored;
- } catch {}
- return document.documentElement.classList.contains("dark") ? "dark" : "light";
-}
-
-function applyTheme(theme: "light" | "dark") {
- if (theme === "dark") {
- document.documentElement.classList.add("dark");
- } else {
- document.documentElement.classList.remove("dark");
- }
- try {
- localStorage.setItem("theme", theme);
- } catch {}
-}
+import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme";
export default function SettingsPage() {
const { student } = useCurrentStudent();
- const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
+ const [theme, setTheme] = useState(getStoredTheme);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
function toggleTheme() {
diff --git a/e2e/tests/faculty-profile-ui.spec.ts b/e2e/tests/faculty-profile-ui.spec.ts
new file mode 100644
index 0000000..1fbc16e
--- /dev/null
+++ b/e2e/tests/faculty-profile-ui.spec.ts
@@ -0,0 +1,38 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Faculty profile settings", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/dashboard/faculty/settings");
+ });
+
+ test("renames the faculty member from the profile card", async ({ page }) => {
+ await page.getByRole("button", { name: "Edit name" }).click();
+ await page.getByLabel("First name").fill("Renamed");
+ await page.getByLabel("Last name").fill("Faculty");
+
+ await page.getByRole("button", { name: "Save name" }).click();
+ await expect(page.getByText("Renamed Faculty").first()).toBeVisible();
+ await page.reload();
+ await expect(page.getByText("Renamed Faculty").first()).toBeVisible();
+ });
+
+ test("uploads and removes a profile photo", async ({ page }) => {
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await page.getByRole("button", { name: /Upload photo|Change photo/ }).click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles({
+ name: "avatar.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+ });
+
+ const avatar = page.getByRole("img", { name: "Profile photo" });
+ await expect(avatar).toHaveAttribute("src", /\/files\//);
+
+ await page.getByRole("button", { name: "Remove photo" }).click();
+ await expect(page.getByRole("button", { name: "Upload photo" })).toBeVisible();
+ });
+});
diff --git a/e2e/tests/faculty-profile.spec.ts b/e2e/tests/faculty-profile.spec.ts
new file mode 100644
index 0000000..aaaad23
--- /dev/null
+++ b/e2e/tests/faculty-profile.spec.ts
@@ -0,0 +1,83 @@
+import { test, expect } from "@playwright/test";
+import { CS17Profile, ensureSessionFaculty, getProfileForUser } from "../helpers/cs17";
+import { callMethod, deleteDoc, getDoc, updateDoc } from "../helpers/frappe";
+
+const UPDATE = "cs17_portal.api.update_my_profile";
+
+interface UpdatedProfile {
+ name: string;
+ full_name: string;
+ profile_picture: string | null;
+}
+
+test.describe("Faculty profile editing", () => {
+ let profileName: string;
+ let createdProfile: string | null = null;
+ let original: CS17Profile;
+
+ test.beforeAll(async ({ request }) => {
+ createdProfile = await ensureSessionFaculty(request);
+ const profile = await getProfileForUser(request, "Administrator");
+ profileName = profile!.name;
+ original = await getDoc(request, "CS17 Profile", profileName);
+ });
+
+ test.afterAll(async ({ request }) => {
+ if (createdProfile) {
+ await deleteTestProfileSafely(request, createdProfile);
+ return;
+ }
+ await updateDoc(request, "CS17 Profile", profileName, {
+ first_name: original.first_name,
+ last_name: original.last_name,
+ profile_picture: original.profile_picture ?? null,
+ });
+ });
+
+ async function deleteTestProfileSafely(request: any, name: string) {
+ try {
+ await deleteDoc(request, "CS17 Profile", name);
+ } catch (error) {
+ console.warn(`Failed to delete profile ${name}:`, error);
+ }
+ }
+
+ test("saves the name and derives full_name", async ({ request }) => {
+ const updated = await callMethod(request, UPDATE, {
+ first_name: "Renamed",
+ last_name: "Faculty",
+ });
+
+ expect(updated.full_name).toBe("Renamed Faculty");
+ const doc = await getDoc(request, "CS17 Profile", profileName);
+ expect(doc.first_name).toBe("Renamed");
+ expect(doc.last_name).toBe("Faculty");
+ });
+
+ test("sets and clears the profile picture", async ({ request }) => {
+ await callMethod(request, UPDATE, {
+ first_name: "Photo",
+ last_name: "Faculty",
+ profile_picture: "/files/e2e-avatar.png",
+ });
+ let doc = await getDoc(request, "CS17 Profile", profileName);
+ expect(doc.profile_picture).toBe("/files/e2e-avatar.png");
+
+ await callMethod(request, UPDATE, {
+ first_name: "Photo",
+ last_name: "Faculty",
+ profile_picture: "",
+ });
+ doc = await getDoc(request, "CS17 Profile", profileName);
+ expect(doc.profile_picture).toBeFalsy();
+ });
+
+ test("rejects a blank last name", async ({ request }) => {
+ await expect(
+ callMethod(request, UPDATE, {
+ first_name: "OnlyFirst",
+ last_name: "",
+ }),
+ ).rejects.toThrow();
+ });
+});
diff --git a/e2e/tests/student-theme.spec.ts b/e2e/tests/student-theme.spec.ts
new file mode 100644
index 0000000..fc86a6b
--- /dev/null
+++ b/e2e/tests/student-theme.spec.ts
@@ -0,0 +1,38 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Theme persistence", () => {
+ test("keeps dark mode across a refresh and on other pages", async ({ page }) => {
+ await page.goto("/dashboard/settings");
+ await page.getByRole("switch").click();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ await page.reload();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+ await expect(page.getByRole("switch")).toHaveAttribute("aria-checked", "true");
+
+ await page.goto("/dashboard/projects");
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ await page.goto("/dashboard/settings");
+ await page.getByRole("switch").click();
+ await page.reload();
+ await expect(page.locator("html")).not.toHaveClass(/dark/);
+ });
+
+ test("applies dark before the app bundle runs, so there is no flash", async ({
+ page,
+ }) => {
+ await page.goto("/dashboard/settings");
+ await page.getByRole("switch").click();
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ await page.route("**/dashboard/assets/*.js", async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ await route.continue();
+ });
+
+ await page.goto("/dashboard/projects", { waitUntil: "commit" });
+ await expect(page.locator("html")).toHaveClass(/dark/, { timeout: 1000 });
+ await expect(page.locator("#root")).toBeEmpty();
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 52c9900..0998c2a 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -4,8 +4,8 @@ import path from "path";
const authFile = path.join(__dirname, "e2e", ".auth", "user.json");
const studentAuthFile = path.join(__dirname, "e2e", ".auth", "student.json");
const facultyAuthFile = path.join(__dirname, "e2e", ".auth", "faculty.json");
-const studentSpecs = /student-submission\.spec\.ts/;
-const facultySpecs = /faculty-assignments-ui\.spec\.ts/;
+const studentSpecs = /(student-submission|student-theme)\.spec\.ts/;
+const facultySpecs = /(faculty-assignments-ui|faculty-profile-ui)\.spec\.ts/;
const SITE_HOST = process.env.SITE_HOST || "cs17.portal:8000";
const SITE_DOMAIN = SITE_HOST.split(":")[0];