diff --git a/csm_web/csm_web/settings.py b/csm_web/csm_web/settings.py index 9b90558f9..9d852929c 100644 --- a/csm_web/csm_web/settings.py +++ b/csm_web/csm_web/settings.py @@ -67,6 +67,7 @@ "frontend", "django_extensions", "django.contrib.postgres", + "storages", ] SHELL_PLUS_SUBCLASSES_IMPORT = [ModelSerializer, Serializer, DjangoModelFactory] @@ -172,7 +173,7 @@ AWS_S3_FILE_OVERWRITE = False AWS_DEFAULT_ACL = None AWS_S3_VERIFY = True -AWS_QUERYSTRING_AUTH = False # public bucket +AWS_QUERYSTRING_AUTH = True STORAGES = { "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, @@ -239,6 +240,8 @@ "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_PARSER_CLASSES": [ "djangorestframework_camel_case.parser.CamelCaseJSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", ], } diff --git a/csm_web/frontend/src/components/App.tsx b/csm_web/frontend/src/components/App.tsx index 00e27a42c..b5fb4c1ed 100644 --- a/csm_web/frontend/src/components/App.tsx +++ b/csm_web/frontend/src/components/App.tsx @@ -9,6 +9,7 @@ import { emptyRoles, Roles } from "../utils/user"; import CourseMenu from "./CourseMenu"; import Home from "./Home"; import Policies from "./Policies"; +import UserProfile from "./UserProfile"; import { DataExport } from "./data_export/DataExport"; import { EnrollmentMatcher } from "./enrollment_automation/EnrollmentMatcher"; import { Resources } from "./resource_aggregation/Resources"; @@ -42,6 +43,7 @@ const App = () => { } /> } /> } /> + } /> } /> @@ -80,7 +82,7 @@ function Header(): React.ReactElement { }; /** - * Helper function to determine class name for the home NavLInk component; + * Helper function to determine class name for the home NavLink component; * is always active unless we're in another tab. */ const homeNavlinkClass = () => { @@ -141,6 +143,9 @@ function Header(): React.ReactElement {

Policies

+ +

Profile

+
diff --git a/csm_web/frontend/src/components/CourseMenu.tsx b/csm_web/frontend/src/components/CourseMenu.tsx index a4d67e90e..3796a4692 100644 --- a/csm_web/frontend/src/components/CourseMenu.tsx +++ b/csm_web/frontend/src/components/CourseMenu.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useState } from "react"; import { Link, Route, Routes } from "react-router-dom"; import { DEFAULT_LONG_LOCALE_OPTIONS, DEFAULT_TIMEZONE } from "../utils/datetime"; -import { useUserInfo } from "../utils/queries/base"; import { useCourses } from "../utils/queries/courses"; +import { useUserInfo } from "../utils/queries/profiles"; import { Course as CourseType, UserInfo } from "../utils/types"; import LoadingSpinner from "./LoadingSpinner"; import Course from "./course/Course"; diff --git a/csm_web/frontend/src/components/UserProfile.tsx b/csm_web/frontend/src/components/UserProfile.tsx new file mode 100644 index 000000000..930a8fd46 --- /dev/null +++ b/csm_web/frontend/src/components/UserProfile.tsx @@ -0,0 +1,411 @@ +import React, { useState } from "react"; +import { Routes, Route, useParams } from "react-router-dom"; +import { PermissionError } from "../utils/queries/helpers"; +import { UpdateUserMutationResponse, useUserInfo, useUserInfoUpdateMutation } from "../utils/queries/profiles"; +import LoadingSpinner from "./LoadingSpinner"; +import { Tooltip } from "./Tooltip"; +import ExclamationCircle from "../../static/frontend/img/exclamation-circle.svg"; +import InfoIcon from "../../static/frontend/img/info.svg"; +import LogoNoText from "../../static/frontend/img/logo_no_text.svg"; +import Upload from "../../static/frontend/img/upload.svg"; +import XIcon from "../../static/frontend/img/x.svg"; + +import "../css/base/form.scss"; +import "../css/base/table.scss"; +import "../css/profile.scss"; + +export interface FormUserInfo { + preferredName: string; + bio: string; + pronouns: string; + pronunciation: string; + profileImage: string; +} + +const MAX_SIZE_MB = 2; +const MAX_FILE_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024; + +const UserProfile: React.FC = () => { + return ( + + } /> + } /> + + ); +}; + +const UserProfileContent: React.FC = () => { + let userId = Number(useParams().id); + + // We always need to get the current user and the viewed profile to check if the current profile page being viewed is the current user (and therefore editable) + const { data: currUserData, isError: isCurrUserError, isLoading: currUserIsLoading } = useUserInfo(); + const { data: requestedData, error: requestedError, isLoading: requestedIsLoading } = useUserInfo(userId); + + const updateMutation = useUserInfoUpdateMutation(); + + const [viewing, setViewing] = useState(true); + const [showSaveSpinner, setShowSaveSpinner] = useState(false); + const [validationText, setValidationText] = useState(""); + + const [formData, setFormData] = useState({ + preferredName: "", + bio: "", + pronouns: "", + pronunciation: "", + profileImage: "" + }); + const [file, setFile] = useState(""); + + // If loading, return loading spinner + if (requestedIsLoading || currUserIsLoading) { + return ; + } + + // If error, state error + if (requestedError || isCurrUserError) { + if (requestedError instanceof PermissionError) { + return

Permission Denied

; + } else { + return

Failed to fetch user data

; + } + } + + // Update current user ID when known + if (Number.isNaN(userId) && requestedData) { + userId = requestedData.id; + } + + // Handle input changes + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + // Handle file upload change + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const selectedFile = e.target.files[0]; + + if (!["image/png", "image/jpeg"].includes(selectedFile.type)) { + setValidationText(`File is not a PNG or JPEG.`); + } else if (selectedFile.size > MAX_FILE_SIZE_BYTES) { + setValidationText(`Image size exceeds maximum allowed size of ${MAX_SIZE_MB}MB.`); + } else { + setFile(selectedFile); + setValidationText(""); + } + } + }; + + // Handle file deletion change + const handleFileDeletion = () => { + setFile(""); + setFormData(prev => ({ ...prev, profileImage: "" })); + }; + + // Handle form submission + const handleFormSubmit = () => { + setShowSaveSpinner(true); + + // Length checks + if (formData["preferredName"].length > 100) { + setValidationText("Preferred name is over 100 characters."); + setShowSaveSpinner(false); + return; + } else if (formData["pronouns"].length > 50) { + setValidationText("Pronouns are over 50 characters."); + setShowSaveSpinner(false); + return; + } else if (formData["pronunciation"].length > 100) { + setValidationText("Pronounciation is over 100 characters."); + setShowSaveSpinner(false); + return; + } else if (formData["bio"].length > 700) { + setValidationText("Bio is over 700 characters."); + setShowSaveSpinner(false); + return; + } + + const userInfo = new FormData(); + userInfo.append("id", userId.toString()); + + for (const [requestField, formField] of [ + ["preferred_name", "preferredName"], + ["bio", "bio"], + ["pronouns", "pronouns"], + ["pronunciation", "pronunciation"], + ["profile_image_link", "profileImage"] + ]) { + userInfo.append(requestField, formData[formField as keyof FormUserInfo]); + } + + if (file) { + userInfo.append("file", file); + } + + const dataObject: { [key: string]: string } = {}; + userInfo.forEach((value, key) => { + dataObject[key] = value as string; + }); + + updateMutation.mutate(userInfo, { + onSuccess: () => { + setFile(""); + setValidationText(""); + setViewing(true); + setShowSaveSpinner(false); + }, + onError: ({ detail }: UpdateUserMutationResponse) => { + setValidationText(detail); + setShowSaveSpinner(false); + } + }); + }; + + // Check if page is editable (current user matches viewed profile) + const canEdit = currUserData?.id === requestedData?.id || requestedData.isEditable; + + // Toggle edit mode + const handleEditToggle = () => { + setFormData({ + preferredName: requestedData.preferredName || "", + bio: requestedData.bio || "", + pronouns: requestedData.pronouns || "", + pronunciation: requestedData.pronunciation || "", + profileImage: requestedData.profileImage || "" + }); + setFile(""); + setViewing(false); + }; + + const handleCancelToggle = () => { + setViewing(true); + }; + + return ( +
+
+ {canEdit && ( + } + className="user-profile-tooltip" + > +
+ Edit your profile so others can learn about you. + {requestedData.roles.includes("mentor") ? ( + <> +
+
+ This profile is visible to everyone. + + ) : requestedData.roles.includes("student") ? ( + <> +
+
+ This profile is visible to your mentors and coordinators. + + ) : ( + "" + )} +
+
+ )} + {viewing ? ( + <> +
+
+ {requestedData.profileImage?.trim() ? ( + + ) : ( +
+
+
+

+ {requestedData.preferredName} + {requestedData.pronouns?.trim() && ( + <> +   + [{requestedData.pronouns.toLowerCase()}] + + )} +

+
+ +
+ {requestedData.pronunciation?.trim() && ( + <> +

{requestedData.pronunciation}

+ + )} +
+ +
+

{requestedData?.email}

+
+
+
+
+ {requestedData.bio?.trim() &&

{requestedData.bio}

} +
+ +
+ {canEdit && ( + + )} +
+ + ) : ( + <> +
+
+ + + {formData.profileImage?.trim() || file ? ( + + ) : null} +
+
+
+
+ + } + className="user-profile-preferred-name-tooltip" + > +
+ A blank name field will default to +
+ your first and last name. +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +