diff --git a/.github/workflows/main-deploy-client.yml b/.github/workflows/main-deploy-client.yml index 9a315dda..09d43819 100644 --- a/.github/workflows/main-deploy-client.yml +++ b/.github/workflows/main-deploy-client.yml @@ -25,6 +25,7 @@ jobs: echo "REACT_APP_PUBLIC_POSTHOG_KEY=${{ secrets.REACT_APP_PUBLIC_POSTHOG_KEY }}" >> .env echo "REACT_APP_PUBLIC_POSTHOG_HOST=${{ secrets.REACT_APP_PUBLIC_POSTHOG_HOST }}" >> .env echo "DISABLE_CAPTURE=${{ env.DISABLE_CAPTURE }}" >> .env + echo "COLLEGE_SCORECARD_KEY=${{ secrets.COLLEGE_SCORECARD_KEY }}" >> .env echo "BETTER_AUTH_SECRET=${{ secrets.BETTER_AUTH_SECRET }}" >> .env echo "BETTER_AUTH_URL=${{ secrets.BETTER_AUTH_URL }}" >> .env diff --git a/.github/workflows/main-pr-build-test.yml b/.github/workflows/main-pr-build-test.yml index cf6b60eb..16f76e61 100644 --- a/.github/workflows/main-pr-build-test.yml +++ b/.github/workflows/main-pr-build-test.yml @@ -177,6 +177,7 @@ jobs: echo "REACT_APP_PUBLIC_POSTHOG_KEY=${{ secrets.REACT_APP_PUBLIC_POSTHOG_KEY }}" >> .env echo "REACT_APP_PUBLIC_POSTHOG_HOST=${{ secrets.REACT_APP_PUBLIC_POSTHOG_HOST }}" >> .env echo "DISABLE_CAPTURE=${{ env.DISABLE_CAPTURE }}" >> .env + echo "COLLEGE_SCORECARD_KEY=${{ secrets.COLLEGE_SCORECARD_KEY }}" >> .env echo "BETTER_AUTH_SECRET=${{ secrets.BETTER_AUTH_SECRET }}" >> .env echo "BETTER_AUTH_URL=${{ env.BETTER_AUTH_URL }}" >> .env echo "TEST_USER_EMAIL=${{ env.TEST_USER_EMAIL }}" >> .env diff --git a/client/src/components/ActorTag/index.tsx b/client/src/components/ActorTag/index.tsx index 3fb9bfb4..34e00c9c 100644 --- a/client/src/components/ActorTag/index.tsx +++ b/client/src/components/ActorTag/index.tsx @@ -113,7 +113,7 @@ const ActorTag = (props: ActorTagProps) => { bg={"white"} minW={"120px"} w={"fit-content"} - maxW={"180px"} + maxW={"200px"} h={"52px"} > { // Error state const [sessionError, setSessionError] = useState(false); + // `true` when the user authenticated via a third-party but hasn't completed their profile + const [incompleteProfile, setIncompleteProfile] = useState(false); + /** - * Helper function to validate session + * Helper function to validate session and check profile completion state */ const getSession = async () => { // Retrieve the session information @@ -89,6 +92,11 @@ const Page: FC = () => { email: sessionResponse.data.user.email, name: sessionResponse.data.user.name, }); + + // Force user to the profile completion page if required + if (sessionResponse.data.user.completedProfile === false) { + setIncompleteProfile(true); + } } }; @@ -96,6 +104,10 @@ const Page: FC = () => { getSession(); }, []); + if (incompleteProfile) { + return ; + } + if (session) { // Display content return ( diff --git a/client/src/components/Icon/index.tsx b/client/src/components/Icon/index.tsx index de72079c..3852e41d 100644 --- a/client/src/components/Icon/index.tsx +++ b/client/src/components/Icon/index.tsx @@ -12,6 +12,7 @@ import { BsArrowRightCircleFill, BsArrowUpRight, BsArrowsAngleExpand, + BsBank2, BsBarChartFill, BsBellFill, BsBoxArrowRight, @@ -123,6 +124,7 @@ export const SYSTEM_ICONS: Record = { grid: BsGrid3X2, upload: BsFillCloudUploadFill, cross: BsXCircleFill, + institution: BsBank2, list: BsList, save: BsFloppyFill, logout: BsBoxArrowRight, diff --git a/client/src/components/SearchSelect/index.tsx b/client/src/components/SearchSelect/index.tsx index b1af2445..6a5ff5d1 100644 --- a/client/src/components/SearchSelect/index.tsx +++ b/client/src/components/SearchSelect/index.tsx @@ -1,13 +1,13 @@ // React imports -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; // Custom and existing components -import { Input, Flex, InputGroup, Text, Spinner, Box } from "@chakra-ui/react"; +import { Input, Flex, InputGroup, Text, Spinner, Box, Separator } from "@chakra-ui/react"; import Icon from "@components/Icon"; import { toaster } from "@components/Toast"; // Custom types -import { EntityModel, IGenericItem, SearchSelectProps } from "@types"; +import { EntityModel, IconNames, IGenericItem, SearchSelectProps } from "@types"; // Utility imports import { debounce } from "lodash"; @@ -21,6 +21,9 @@ import { useWorkspace } from "@hooks/useWorkspace"; // Variables import { GLOBAL_STYLES } from "@variables"; +// College Scorecard API URL +const SCORECARD_URL = "https://api.data.gov/ed/collegescorecard/v1/schools"; + const GET_ENTITIES = gql` query GetEntities($limit: Int, $archived: Boolean) { entities(limit: $limit, archived: $archived) { @@ -65,18 +68,19 @@ const SearchSelect = (props: SearchSelectProps) => { const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const [isAnimating, setIsAnimating] = useState(false); - const [getEntities, { loading: entitiesLoading, error: entitiesError }] = useLazyQuery<{ + const [getEntities, { loading: entitiesLoading }] = useLazyQuery<{ entities: { entities: IGenericItem[]; total: number }; }>(GET_ENTITIES, { fetchPolicy: "network-only" }); - const [getProjects, { loading: projectsLoading, error: projectsError }] = useLazyQuery<{ + const [getProjects, { loading: projectsLoading }] = useLazyQuery<{ projects: IGenericItem[]; }>(GET_PROJECTS, { fetchPolicy: "network-only" }); - const [searchText, { loading: searchLoading, error: searchError }] = useLazyQuery<{ search: EntityModel[] }>( - SEARCH_TEXT, - { fetchPolicy: "network-only" }, - ); + const [searchText, { loading: searchLoading }] = useLazyQuery<{ search: EntityModel[] }>(SEARCH_TEXT, { + fetchPolicy: "network-only", + }); + + const [institutionLoading, setInstitutionLoading] = useState(false); const [inputValue, setInputValue] = useState(props.value?.name || ""); const [options, setOptions] = useState([]); @@ -84,82 +88,186 @@ const SearchSelect = (props: SearchSelectProps) => { const [showResults, setShowResults] = useState(false); const [hasSearched, setHasSearched] = useState(false); const [isTyping, setIsTyping] = useState(false); - - const placeholder = - props.placeholder ?? (props.resultType === "entity" ? "Search Entities..." : "Search Projects..."); - - const isLoading = entitiesLoading || projectsLoading || searchLoading || (isTyping && !hasSearched); - - const iconName = props.resultType === "entity" ? "entity" : "project"; - const iconColor = props.resultType === "entity" ? GLOBAL_STYLES.entity.color.icon : GLOBAL_STYLES.project.color.icon; + const isLoading = + entitiesLoading || projectsLoading || searchLoading || institutionLoading || (isTyping && !hasSearched); + + // Setup default presentation parameters + let placeholder = "Search Entities..."; + let iconName: IconNames = "entity"; + let iconColor = GLOBAL_STYLES.entity.color.icon; + + switch (props.resultType) { + case "institution": { + placeholder = "Search Institutions..."; + iconName = "institution"; + iconColor = "gray.600"; + break; + } + case "project": { + placeholder = "Search Projects..."; + iconName = "project"; + iconColor = GLOBAL_STYLES.project.color.icon; + break; + } + } useEffect(() => { setInputValue(props.value?.name || ""); }, [props.value]); + /** + * Loads the initial dropdown options on mount and on workspace change + */ const getSelectOptions = async () => { - if (props.resultType === "entity") { - const result = await getEntities({ variables: { limit: 20 } }); - if (result.data?.entities?.entities) setOptions(result.data.entities.entities); - } else { - const result = await getProjects({ variables: { limit: 20 } }); - if (result.data?.projects) setOptions(result.data.projects); - } - - if (entitiesError || projectsError) { - toaster.create({ - title: "Error", - type: "error", - description: "Error while retrieving options for selection", - duration: 4000, - closable: true, - }); + switch (props.resultType) { + case "institution": { + // Pre-poll a handful of large institutions so the dropdown isn't empty on first open. + setInstitutionLoading(true); + try { + const params = new URLSearchParams({ + api_key: process.env.COLLEGE_SCORECARD_KEY || "", + fields: "school.name", + per_page: "5", + "school.operating": "1", + _sort: "latest.student.size:desc", + }); + const res = await fetch(`${SCORECARD_URL}?${params}`); + const data = await res.json(); + setOptions( + (data.results || []) + .map((r: Record) => r["school.name"]) + .filter(Boolean) + .map((name: string) => ({ _id: name, name })), + ); + } catch { + // Non-critical, user can still type to search. + } finally { + setInstitutionLoading(false); + } + return; + } + case "entity": { + const result = await getEntities({ variables: { limit: 20 } }); + if (result.data?.entities?.entities) { + setOptions(result.data.entities.entities); + } else if (result.error) { + toaster.create({ + title: "Error", + type: "error", + description: "Error while retrieving options for selection", + duration: 4000, + closable: true, + }); + } + break; + } + case "project": { + const result = await getProjects({ variables: { limit: 20 } }); + if (result.data?.projects) { + setOptions(result.data.projects); + } else if (result.error) { + toaster.create({ + title: "Error", + type: "error", + description: "Error while retrieving options for selection", + duration: 4000, + closable: true, + }); + } + break; + } } }; const { workspace } = useWorkspace(); - - useEffect(() => { - getSelectOptions().catch(ignoreAbort); - }, []); - useEffect(() => { getSelectOptions().catch(ignoreAbort); }, [workspace]); + // First item whose name starts with the current input, used for Tab-to-complete ghost text. const topSuggestion = useMemo(() => { if (!inputValue) return null; const list = hasSearched ? results : options; return list.find((item) => item.name.toLowerCase().startsWith(inputValue.toLowerCase())) || null; }, [inputValue, hasSearched, results, options]); + /** + * Queries the College Scorecard API and populates results with matching institution names + */ + const fetchInstitutionResults = useCallback(async (query: string) => { + setInstitutionLoading(true); + try { + const params = new URLSearchParams({ + api_key: process.env.COLLEGE_SCORECARD_KEY || "", + "school.name": query, + fields: "school.name", + per_page: "20", + "school.operating": "1", + }); + const res = await fetch(`${SCORECARD_URL}?${params}`); + const data = await res.json(); + setResults( + (data.results || []) + .map((r: Record) => r["school.name"]) + .filter(Boolean) + .map((name: string) => ({ _id: name, name })), + ); + setHasSearched(true); + setShowResults(true); + } catch { + toaster.create({ + title: "Error", + type: "error", + description: "Failed to load institutions", + duration: 4000, + closable: true, + }); + } finally { + setInstitutionLoading(false); + } + }, []); + + /** + * Runs the GraphQL search query and populates results for entities or projects + */ + const fetchGraphQLResults = useCallback( + async (query: string) => { + const response = await searchText({ + variables: { query, resultType: props.resultType, isBuilder: false, showArchived: false }, + }).catch(ignoreAbort); + if (!response) return; + + if (response.data?.search) { + setResults(response.data.search); + setHasSearched(true); + setShowResults(true); + } else { + setResults([]); + toaster.create({ + title: "Error", + type: "error", + description: "Unable to retrieve search results", + duration: 4000, + closable: true, + }); + } + }, + [searchText, props.resultType], + ); + + /** + * Debounced dispatcher that routes to the appropriate fetch helper based on `resultType` + */ const fetchResults = useMemo( () => - debounce(async (query: string) => { - const response = await searchText({ - variables: { query, resultType: props.resultType, isBuilder: false, showArchived: false }, - }).catch(ignoreAbort); - if (!response) return; - - if (response.data?.search) { - setResults(response.data.search); - setHasSearched(true); - setShowResults(true); + debounce((query: string) => { + if (props.resultType === "institution") { + fetchInstitutionResults(query); } else { - setResults([]); - } - - if (searchError || !response.data?.search) { - toaster.create({ - title: "Error", - type: "error", - description: searchError || "Unable to retrieve search results", - duration: 4000, - closable: true, - }); + fetchGraphQLResults(query); } }, 300), - [searchText], + [fetchInstitutionResults, fetchGraphQLResults, props.resultType], ); const updateDropdownPosition = () => { @@ -174,6 +282,7 @@ const SearchSelect = (props: SearchSelectProps) => { const openDropdown = () => { updateDropdownPosition(); + setInputValue(""); setShowResults(true); setTimeout(() => setIsAnimating(true), 10); }; @@ -181,6 +290,7 @@ const SearchSelect = (props: SearchSelectProps) => { const closeDropdown = () => { setIsAnimating(false); setIsTyping(false); + setInputValue(props.value?.name || ""); setTimeout(() => setShowResults(false), 150); }; @@ -215,28 +325,38 @@ const SearchSelect = (props: SearchSelectProps) => { }; const handleKeyDown = (e: React.KeyboardEvent) => { + // Tab completes the ghost suggestion inline rather than moving focus if (e.key === "Tab" && topSuggestion) { e.preventDefault(); handleSelectResult(topSuggestion); } }; + const defaultItem: IGenericItem | null = props.defaultOption + ? { _id: props.defaultOption, name: props.defaultOption } + : null; + // Puts the default option at the top, deduplicating it from the rest of the list + const prependDefault = (items: IGenericItem[]) => + defaultItem ? [defaultItem, ...items.filter((i) => i._id !== defaultItem._id)] : items; + const renderItems = (items: IGenericItem[]) => ( {items.map((item) => ( - handleSelectResult(item)} - > - - {item.name} - + + handleSelectResult(item)} + > + + {item.name} + + {defaultItem && item._id === defaultItem._id && items.length > 1 && } + ))} ); @@ -251,7 +371,7 @@ const SearchSelect = (props: SearchSelectProps) => { endElement={showResults ? : } > { disabled={props?.disabled || false} /> + {props.value?._id && !showResults && ( )} + {topSuggestion && ( @@ -304,31 +426,32 @@ const SearchSelect = (props: SearchSelectProps) => { )} + {showResults && ( - <> + { )} - {!isLoading && hasSearched && results.length > 0 && renderItems(results)} - {!isLoading && hasSearched && results.length === 0 && ( + {!isLoading && hasSearched && prependDefault(results).length > 0 && renderItems(prependDefault(results))} + {!isLoading && hasSearched && results.length === 0 && !defaultItem && ( No Results )} - {!isLoading && !hasSearched && !isTyping && options.length > 0 && renderItems(options)} + {!isLoading && + !hasSearched && + !isTyping && + prependDefault(options).length > 0 && + renderItems(prependDefault(options))} - + )} ); diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index b2d6097a..c55da6b8 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -33,6 +33,9 @@ export const auth = createAuthClient({ account_orcid: { type: "string", }, + completedProfile: { + type: "boolean", + }, }, }), ], diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 19d5f99f..6ba3c62c 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -456,6 +456,7 @@ const Dashboard = () => { const { action, type } = data; if ((action === ACTIONS.SKIP || type === EVENTS.TOUR_END) && session?.user) { updateUser({ variables: { user: { _id: session.user.id, hasSeenWalkthrough: true } } }); + auth.updateUser({ hasSeenWalkthrough: true }); } }; diff --git a/client/src/pages/account/Login.tsx b/client/src/pages/account/Login.tsx index 76aa5ec5..dffac221 100644 --- a/client/src/pages/account/Login.tsx +++ b/client/src/pages/account/Login.tsx @@ -55,19 +55,12 @@ const Login = () => { if (session?.user) { // User is logged in, check if profile is complete - if (session.user.email?.endsWith("@orcid.placeholder")) { - // ORCiD login found no existing account, so delete the orphaned - // placeholder account - await auth.deleteUser(); + if (session.user.email?.endsWith("@orcid.placeholder") || !session.user.completedProfile) { + // ORCiD login with incomplete profile: redirect to complete profile + // rather than deleting the account, which would erase existing users + // whose accounts have a placeholder email due to prior signup errors window.history.replaceState({}, document.title, "/login"); - toaster.create({ - title: "No Account Found", - description: - "No account is linked to this ORCiD. Please sign up first, or log in with email and password and link ORCiD from Account Settings.", - type: "error", - duration: 6000, - closable: true, - }); + navigate("/signup"); } else { // Profile complete, redirect to dashboard window.history.replaceState({}, document.title, "/login"); diff --git a/client/src/pages/account/Signup.tsx b/client/src/pages/account/Signup.tsx index ccb3ffad..b5b3742f 100644 --- a/client/src/pages/account/Signup.tsx +++ b/client/src/pages/account/Signup.tsx @@ -10,14 +10,12 @@ import { Input, Fieldset, Field, - Select, - createListCollection, Text, Separator, Box, AbsoluteCenter, - Spacer, } from "@chakra-ui/react"; +import SearchSelect from "@components/SearchSelect"; import { Content } from "@components/Container"; import Icon from "@components/Icon"; import { toaster } from "@components/Toast"; @@ -45,7 +43,10 @@ import { IResponseMessage } from "@types"; import { gql } from "@apollo/client"; import { useMutation } from "@apollo/client/react"; -// GraphQL mutation to create user profile +// "signup" for new email accounts, "complete" for third-party accounts that need profile info +type SignupPageMode = "loading" | "signup" | "complete"; + +// GraphQL mutation to update an existing user record during profile completion const UPDATE_USER = gql` mutation UpdateUser($user: UserInput) { updateUser(user: $user) { @@ -57,122 +58,103 @@ const UPDATE_USER = gql` const Signup = () => { const navigate = useNavigate(); - const isDevelopment = process.env.NODE_ENV === "development"; // GraphQL mutation hook const [updateUser, { loading: updateUserLoading }] = useMutation<{ updateUser: IResponseMessage; }>(UPDATE_USER); + // Page mode resolves from "loading" once the session check completes + const [mode, setMode] = useState("loading"); + + // Session-derived identifiers, only populated in complete profile mode + const [userId, setUserId] = useState(""); + const [provider, setProvider] = useState(""); + // User information state - const [userFirstName, setUserFirstName] = useState(""); - const [userLastName, setUserLastName] = useState(""); - const [userEmail, setUserEmail] = useState(""); - const [userAffiliation, setUserAffiliation] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [affiliation, setAffiliation] = useState(""); // Email validation state const [emailError, setEmailError] = useState(""); const [isEmailValid, setIsEmailValid] = useState(false); - // Password validation state + // Password state, signup mode only const [initialPassword, setInitialPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [isPasswordValid, setIsPasswordValid] = useState(false); // Loading state - const [isAccountCreateLoading, setIsAccountCreateLoading] = useState(false); - const [isOrcidLoading, setIsOrcidLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); - // ORCiD state - const [orcidId, setOrcidId] = useState(""); - const [isExistingUser, setIsExistingUser] = useState(false); - const [existingUserId, setExistingUserId] = useState(""); - - // Check if user already has a session (from ORCiD login) + // Determine the page mode from the current session state useEffect(() => { - auth.getSession().then(({ data: session }) => { - if (session?.user?.email?.endsWith("@orcid.placeholder")) { - // User logged in via ORCiD but needs to complete profile - setIsExistingUser(true); - setExistingUserId(session.user.id); - if (session.user.account_orcid) { - setOrcidId(session.user.account_orcid); - } - // Pre-fill name from ORCiD - if (session.user.name) { - const spaceIndex = session.user.name.indexOf(" "); - if (spaceIndex !== -1) { - setUserFirstName(session.user.name.slice(0, spaceIndex)); - setUserLastName(session.user.name.slice(spaceIndex + 1)); - } else { - setUserFirstName(session.user.name); - } + auth.getSession().then(({ data }) => { + if (!data) { + setMode("signup"); + return; + } + if (data.user.completedProfile) { + navigate("/"); + return; + } + setMode("complete"); + setUserId(data.user.id); + if (data.user.account_orcid) { + setProvider("orcid"); + } + // Pre-fill name from the third-party provider if available + if (data.user.name) { + const spaceIndex = data.user.name.indexOf(" "); + if (spaceIndex !== -1) { + setFirstName(data.user.name.slice(0, spaceIndex)); + setLastName(data.user.name.slice(spaceIndex + 1)); + } else { + setFirstName(data.user.name); } } }); }, []); - const userComplete = - userFirstName !== "" && - userLastName !== "" && - userEmail !== "" && - userAffiliation !== "" && - isEmailValid && - (isExistingUser || isPasswordValid); // Password only required for new users - - // Affiliation options collection - const affiliationCollection = createListCollection({ - items: [ - { label: "No Affiliation", value: "No Affiliation" }, - { - label: "Washington University in St. Louis", - value: "Washington University in St. Louis", - }, - ], - }); - /** * Validate email format and update validation state - * @param {string} email Entered email address + * @param {string} value Entered email address */ - const validateEmail = (email: string) => { - const isValid = isValidEmail(email); + const validateEmail = (value: string) => { + const isValid = isValidEmail(value); setIsEmailValid(isValid); - - if (email === "") { - setEmailError(""); - } else if (!isValid) { - setEmailError("Please enter a valid email address"); - } else { - setEmailError(""); - } + setEmailError(value === "" || isValid ? "" : "Please enter a valid email address"); }; /** - * Validate password and matching + * Validate password match and update validation state * @param {string} password Confirmed password string */ const validatePassword = (password: string) => { setConfirmPassword(password); - if (password === "") { - setIsPasswordValid(false); - } else if (password !== initialPassword) { - setIsPasswordValid(false); - } else { - setIsPasswordValid(true); - } + setIsPasswordValid(password !== "" && password === initialPassword); }; + // All required fields are populated and valid + const isFormComplete = + firstName !== "" && + lastName !== "" && + email !== "" && + isEmailValid && + affiliation !== "" && + (mode === "complete" || isPasswordValid); + /** - * Handle ORCiD signup button click + * Handle ORCiD signup button click, redirect to ORCiD authentication */ const onOrcidSignupClick = async () => { - setIsOrcidLoading(true); + setIsLoading(true); const { error, data } = await auth.signIn.social({ provider: "orcid", callbackURL: `${APP_URL}/signup`, }); - if (error) { toaster.create({ title: "ORCiD Authentication Error", @@ -181,119 +163,118 @@ const Signup = () => { duration: 4000, closable: true, }); - setIsOrcidLoading(false); + setIsLoading(false); } else if (data?.url) { window.location.href = data.url; } }; /** - * Handle the "Done" button being clicked after user information is entered + * Handle email and password signup form submission */ - const onDoneClick = async () => { - setIsAccountCreateLoading(true); - const joinedName = `${userFirstName} ${userLastName}`; - - if (isExistingUser) { - // User already has an account from ORCiD login - try { - const result = await updateUser({ - variables: { - user: { - _id: existingUserId, - firstName: userFirstName, - lastName: userLastName, - name: `${userFirstName} ${userLastName}`, - affiliation: userAffiliation, - email: userEmail, - emailVerified: false, - image: "", - createdAt: dayjs(Date.now()).toISOString(), - updatedAt: dayjs(Date.now()).toISOString(), - api_keys: JSON.stringify([]), - account_orcid: orcidId, - }, - }, - }); - - setIsAccountCreateLoading(false); - - if (result.data?.updateUser) { - posthog.capture("client.auth.signup_complete", { method: "orcid" }); + const onSignupClick = async () => { + setIsLoading(true); + await auth.signUp.email( + { + email, + name: `${firstName} ${lastName}`, + password: initialPassword, + firstName, + lastName, + affiliation, + lastLogin: dayjs(Date.now()).toISOString(), + api_keys: JSON.stringify([]), + account_orcid: "", + completedProfile: true, + callbackURL: `${APP_URL}/login`, + hasSeenWalkthrough: false, + }, + { + onSuccess: () => { + setIsLoading(false); + posthog.capture("client.auth.signup_complete", { method: "email" }); toaster.create({ - title: "User Created", + title: "Create Account", type: "success", - description: "Your account has been created successfully!", + description: "Account created successfully!", duration: 4000, closable: true, }); - navigate("/"); - } else { + navigate("/login"); + }, + onError: (ctx) => { + setIsLoading(false); toaster.create({ - title: "Failed to Create Account", + title: "Create Account", type: "error", - description: "Failed to create account. Please try again.", + description: ctx.error.message || "An unknown error occurred. Please try again.", duration: 4000, closable: true, }); - } - } catch { - setIsAccountCreateLoading(false); + }, + }, + ); + }; + + /** + * Handle profile completion form submission for third-party signups + */ + const onCompleteClick = async () => { + setIsLoading(true); + try { + const result = await updateUser({ + variables: { + user: { + _id: userId, + firstName, + lastName, + name: `${firstName} ${lastName}`, + email, + affiliation, + completedProfile: true, + updatedAt: dayjs(Date.now()).toISOString(), + }, + }, + }); + setIsLoading(false); + if (result.data?.updateUser.success) { + await auth.updateUser({ completedProfile: true }); + posthog.capture("client.auth.signup_complete", { method: provider || "third_party" }); + navigate("/"); + } else if (result.data?.updateUser.message === "EMAIL_EXISTS") { + toaster.create({ + title: "Email Already in Use", + type: "warning", + description: + "An account with this email already exists. Sign in to your existing account and link your ORCiD from Settings.", + duration: 8000, + closable: true, + }); + } else { toaster.create({ - title: "Failed to Create Account", + title: "Failed to Complete Profile", type: "error", - description: "Failed to create account. Please try again.", + description: result.data?.updateUser.message || "Failed to complete profile. Please try again.", duration: 4000, closable: true, }); } - } else { - // New user, create account - await auth.signUp.email( - { - email: userEmail, - name: joinedName, - password: initialPassword, - firstName: userFirstName, - lastName: userLastName, - affiliation: userAffiliation, - lastLogin: dayjs(Date.now()).toISOString(), - api_keys: JSON.stringify([]), - account_orcid: orcidId, - callbackURL: `${APP_URL}/login`, - hasSeenWalkthrough: false, - }, - { - onRequest: () => { - setIsAccountCreateLoading(true); - }, - onSuccess: () => { - setIsAccountCreateLoading(false); - posthog.capture("client.auth.signup_complete", { method: "email" }); - toaster.create({ - title: "Create Account", - type: "success", - description: "Account created successfully!", - duration: 4000, - closable: true, - }); - navigate("/login"); - }, - onError: (ctx) => { - setIsAccountCreateLoading(false); - toaster.create({ - title: "Create Account", - type: "error", - description: ctx.error.message || "An unknown error occurred. Please try again.", - duration: 4000, - closable: true, - }); - }, - }, - ); + } catch { + setIsLoading(false); + toaster.create({ + title: "Failed to Complete Profile", + type: "error", + description: "Failed to complete profile. Please try again.", + duration: 4000, + closable: true, + }); } }; + if (mode === "loading") { + return null; + } + return ( @@ -329,207 +310,156 @@ const Signup = () => { - Create your Metadatify account + {mode === "complete" ? "Complete Profile" : "Create your Metadatify account"} + {mode === "complete" && ( + + Provide your name and email address to finish setting up your account. + + )} - - - - First Name - - - setUserFirstName(event.target.value)} - /> - - - - - - Last Name - - - setUserLastName(event.target.value)} - /> - - - - - - - - Email - - - { - setUserEmail(event.target.value); - validateEmail(event.target.value); - }} - /> - - {emailError} - - - - - - - - Affiliation - - - setUserAffiliation(details.value[0] || "")} - > - - - - - - - - - - - - {affiliationCollection.items.map((affiliation) => ( - - {affiliation.label} - - - ))} - - - - - - - - - {!isExistingUser && ( - - - - Password + + + First Name setInitialPassword(event.target.value)} + rounded={"md"} + borderColor={"gray.300"} + _focus={{ + borderColor: "primary", + boxShadow: "0 0 0 1px var(--chakra-colors-primary)", + }} + value={firstName} + onChange={(e) => setFirstName(e.target.value)} /> - - - Confirm Password + + + Last Name validatePassword(event.target.value)} + rounded={"md"} + borderColor={"gray.300"} + _focus={{ + borderColor: "primary", + boxShadow: "0 0 0 1px var(--chakra-colors-primary)", + }} + value={lastName} + onChange={(e) => setLastName(e.target.value)} /> - Passwords do not match - )} - - {orcidId && ( - - - - ORCiD {orcidId} will be linked to your account - - - )} - - {!orcidId && ( - <> - - - - - Optional - - - - - - )} + type={"email"} + borderColor={emailError ? "red.300" : "gray.300"} + _focus={{ + borderColor: "primary", + boxShadow: "0 0 0 1px var(--chakra-colors-primary)", + }} + value={email} + onChange={(e) => { + setEmail(e.target.value); + validateEmail(e.target.value); + }} + /> + + {emailError} + + + + + + Affiliation + + + setAffiliation(item.name)} + defaultOption="Affiliation Not Shown" + /> + + + {mode === "signup" && ( + <> + + + + Password + + + setInitialPassword(e.target.value)} + /> + + + + Confirm Password + + + validatePassword(e.target.value)} + /> + Passwords do not match + + + + + + + + Optional + + + + + + + )} + - {!isExistingUser ? ( + {mode === "signup" && ( - ) : ( - )} - diff --git a/client/src/pages/view/User.tsx b/client/src/pages/view/User.tsx index fd7d0930..2b33b287 100644 --- a/client/src/pages/view/User.tsx +++ b/client/src/pages/view/User.tsx @@ -1,11 +1,25 @@ // React and Chakra UI components import React, { useEffect, useState } from "react"; -import { Flex, Input, Button, Text, Heading, IconButton, Tag, Fieldset, Field, EmptyState } from "@chakra-ui/react"; +import { + Box, + Flex, + Input, + Button, + Text, + Heading, + IconButton, + Tag, + Fieldset, + Field, + EmptyState, +} from "@chakra-ui/react"; +import SearchSelect from "@components/SearchSelect"; import Tooltip from "@components/Tooltip"; import { toaster } from "@components/Toast"; import { createColumnHelper } from "@tanstack/react-table"; // Custom components +import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; import DataTable, { ColumnMeta } from "@components/DataTable"; import Icon from "@components/Icon"; @@ -31,7 +45,6 @@ import { isValidEmail, ignoreAbort } from "@lib/util"; // Variables import { APP_URL, GLOBAL_STYLES } from "@variables"; -import ActorTag from "@components/ActorTag"; const User = () => { const { isBreakpointActive } = useBreakpoint(); @@ -149,7 +162,7 @@ const User = () => { setUserEmail(data.user.email); setEmailVerified(data.user.emailVerified); setUserAffiliation(data.user.affiliation); - setUserKeys(JSON.parse(data.user.api_keys)); + setUserKeys(data.user.api_keys ? JSON.parse(data.user.api_keys) : []); setStaticName(`${data.user.firstName} ${data.user.lastName}`); // Initialize email validation state @@ -171,8 +184,9 @@ const User = () => { } } `; - const [updateUser, { loading: userUpdateLoading, error: userUpdateError }] = - useMutation(UPDATE_USER); + const [updateUser, { loading: userUpdateLoading, error: userUpdateError }] = useMutation<{ + updateUser: IResponseMessage; + }>(UPDATE_USER); // Mutation to revoke an API key const REVOKE_KEY = gql` @@ -286,29 +300,53 @@ const User = () => { return; } - await updateUser({ + const result = await updateUser({ variables: { user: { - _id: userOrcid, + _id: user, email: userEmail, affiliation: userAffiliation, + name: `${userFirstName} ${userLastName}`, + firstName: userFirstName, + lastName: userLastName, }, }, }); - if (userUpdateError) { - toaster.create({ - title: "Error", - description: "Unable to update User information", - type: "error", - duration: 2000, - closable: true, - }); - } else { - // Update the displayed name - setStaticName(`${userFirstName} ${userLastName}`); + if (userUpdateError || !result.data?.updateUser.success) { + if (result.data?.updateUser.message === "EMAIL_EXISTS") { + setEmailError("An account with this email already exists"); + setIsEmailValid(false); + toaster.create({ + title: "Email Already in Use", + description: "An account with this email already exists. Please use a different email address.", + type: "warning", + duration: 4000, + closable: true, + }); + } else { + toaster.create({ + title: "Error", + description: "Unable to update User information", + type: "error", + duration: 2000, + closable: true, + }); + } + return; } + // Update the displayed name + setStaticName(`${userFirstName} ${userLastName}`); + + toaster.create({ + title: "Updated User", + description: "User information successfully updated", + type: "success", + duration: 2000, + closable: true, + }); + setEditing(false); }; @@ -758,7 +796,13 @@ const User = () => { > Avatar - + {/* Name */} @@ -784,7 +828,10 @@ const User = () => { value={userFirstName} mt={"0.5"} bg={"white"} - disabled + disabled={!editing} + onChange={(event) => { + setUserFirstName(event.target.value); + }} /> @@ -806,7 +853,10 @@ const User = () => { value={userLastName} mt={"0.5"} bg={"white"} - disabled + disabled={!editing} + onChange={(event) => { + setUserLastName(event.target.value); + }} /> @@ -855,7 +905,7 @@ const User = () => { }} /> {emailError !== "" && ( - + {emailError} )} @@ -907,20 +957,18 @@ const User = () => { Affiliation - { - setUserAffiliation(event.target.value); - validateAffiliation(event.target.value); - }} - /> + + { + setUserAffiliation(item.name); + validateAffiliation(item.name); + }} + disabled={!editing} + /> + {affiliationError !== "" && ( {affiliationError} diff --git a/client/test/playwright/helpers.ts b/client/test/playwright/helpers.ts index 9001851f..0c8a7d41 100644 --- a/client/test/playwright/helpers.ts +++ b/client/test/playwright/helpers.ts @@ -174,6 +174,7 @@ export const createTestUser = async (context: BrowserContext): Promise = const user = testUtils.createUser({ email: process.env.TEST_USER_EMAIL, name: "Test User", + completedProfile: true, }); await testUtils.saveUser(user); diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts index f87165f6..d9390996 100644 --- a/server/src/lib/auth.ts +++ b/server/src/lib/auth.ts @@ -46,29 +46,32 @@ const getOAuthConfig = () => { mapProfileToUser: async (response: Record) => { // ORCiD userinfo endpoint returns 'sub' as the ORCiD ID per OpenID Connect spec const sub = typeof response.sub === "string" ? response.sub : ""; - const given_name = typeof response.given_name === "string" ? response.given_name : ""; - const family_name = typeof response.family_name === "string" ? response.family_name : ""; - // Note: Returning ORCiD user, look up their real email so better-auth can - // match the session correctly + // Guard against both JSON null and the literal string "null" + const given_name = + typeof response.given_name === "string" && response.given_name !== "null" ? response.given_name : ""; + const family_name = + typeof response.family_name === "string" && response.family_name !== "null" ? response.family_name : ""; + + // Note: Returning ORCiD user, look up their real email so better-auth can match the session correctly const userIdResult = await User.getByOrcid(sub); if (userIdResult.success) { const existingUser = await User.getOne(userIdResult.data); return { account_orcid: sub, email: existingUser?.email, - emailVerified: true, + emailVerified: existingUser?.emailVerified || false, name: `${given_name} ${family_name}`.trim(), }; } - // Note: New user or first-time link, better-auth requires an email, so use a - // placeholder + // Note: New user or first-time link, better-auth requires an email, so use a placeholder return { account_orcid: sub, email: `${sub.replace(/\//g, "-")}@orcid.placeholder`, emailVerified: false, name: `${given_name} ${family_name}`.trim(), + completedProfile: false, // `false` until information provided }; }, }; @@ -161,10 +164,15 @@ export const auth = betterAuth({ }, api_keys: { type: "string", + defaultValue: "[]", }, account_orcid: { type: "string", }, + completedProfile: { + type: "boolean", + defaultValue: false, + }, }, }, }); diff --git a/server/src/models/User.ts b/server/src/models/User.ts index 9d83c90c..0b8c6305 100644 --- a/server/src/models/User.ts +++ b/server/src/models/User.ts @@ -75,20 +75,62 @@ export class User { }; } + // Reject if another account already owns this email, regardless of update context + if (updated.email) { + const existingByEmail = await this.getByEmail(updated.email); + if (existingByEmail.success && existingByEmail.data !== updated._id) { + return { + success: false, + message: "EMAIL_EXISTS", + }; + } + } + + // Profile completion validation + if (updated.completedProfile === true) { + // Ensure valid email is provided (catch instance of ORCiD placeholders too) + if (!updated.email || updated.email.endsWith("@orcid.placeholder")) { + return { + success: false, + message: "A valid email address is required to complete your account", + }; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(updated.email)) { + return { + success: false, + message: "A valid email address is required to complete your account", + }; + } + + // Ensure valid name + if (!updated.firstName || updated.firstName === "null" || !updated.lastName || updated.lastName === "null") { + return { + success: false, + message: "A valid first and last name is required to complete your account", + }; + } + } + const update: { $set: UserModel } = { $set: { ...user, }, }; - if (updated.firstName) { + if (updated.firstName && updated.firstName !== "null") { update.$set.firstName = updated.firstName; } - if (updated.lastName) { + if (updated.lastName && updated.lastName !== "null") { update.$set.lastName = updated.lastName; } + if (updated.name) { + update.$set.name = updated.name; + } + if (updated.email) { update.$set.email = updated.email; } @@ -113,10 +155,14 @@ export class User { update.$set.hasSeenWalkthrough = updated.hasSeenWalkthrough; } + if (!_.isUndefined(updated.completedProfile)) { + update.$set.completedProfile = updated.completedProfile; + } + const response = await getDatabase() .collection(USERS_COLLECTION) .updateOne({ _id: updated._id }, update); - const successStatus = response.modifiedCount == 1; + const successStatus = response.modifiedCount === 1 || response.matchedCount === 1; return { success: successStatus, diff --git a/server/src/typedefs.ts b/server/src/typedefs.ts index 64a07f05..8267b447 100644 --- a/server/src/typedefs.ts +++ b/server/src/typedefs.ts @@ -111,6 +111,7 @@ export const typedefs = `#graphql api_keys: String account_orcid: String hasSeenWalkthrough: Boolean + completedProfile: Boolean } # "Project" type diff --git a/server/test/helpers.ts b/server/test/helpers.ts index 977659b0..ba64772a 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -375,6 +375,10 @@ export const getAuth = async () => { account_orcid: { type: "string", }, + completedProfile: { + type: "boolean", + defaultValue: false, + }, }, }, }); diff --git a/types/index.d.ts b/types/index.d.ts index 4ee31ef9..220a00f4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -526,6 +526,7 @@ export type IconNames = | "grid" | "upload" | "cross" + | "institution" | "list" | "save" | "logout" @@ -636,8 +637,9 @@ export type SearchRuleSelectProps = { export type SearchSelectProps = { id?: string; value: IGenericItem; - resultType: "entity" | "project"; + resultType: "entity" | "project" | "institution"; placeholder?: string; + defaultOption?: string; onChange?: (value: any) => void; disabled?: boolean; isEmbedded?: boolean; @@ -726,12 +728,13 @@ export type IUser = { image: string; // better-auth: Display image URL createdAt: string; // better-auth: Created updatedAt: string; // better-auth: Last updated - lastLogin: string; - hasSeenWalkthrough?: boolean; + lastLogin: string; // better-auth: Timestamp of last login api_keys: string; // better-auth: Stored as a JSON string - account_orcid: string; role: string; // better-auth admin: "user" or "admin" - features: UserFeatures; + features: UserFeatures; // Account features such as AI search or API access + account_orcid: string; // ORCiD if connected + hasSeenWalkthrough?: boolean; // If user has seen or skipped the initial walkthrough + completedProfile?: boolean; // `false` until third-party signup profile is completed }; export type UserModel = IUser & {