From a5fb0c09ce3c6a1582ca648c90e41134a3af144a Mon Sep 17 00:00:00 2001 From: Abhishekfm Date: Tue, 27 Oct 2026 17:51:39 +0530 Subject: [PATCH] Enhance organization profile management and layout - Refactored OrgProfile component to improve state management and data fetching with new queries for contributions and sectors. - Introduced ProfileLayout components for better organization of profile-related UI elements. - Added error handling and validation for form inputs to enhance user experience. - Updated UserProfile component to streamline data handling and improve profile picture management. - Implemented new styles for profile components to enhance visual consistency and usability. --- .../[entitySlug]/profile/orgProfile.tsx | 503 +++++++++----- .../[entitySlug]/profile/page.tsx | 2 +- .../[entitySlug]/profile/profile-layout.tsx | 232 +++++++ .../[entitySlug]/profile/profile.module.scss | 11 + .../[entitySlug]/profile/userProfile.tsx | 616 +++++++++++++----- app/api/auth/change-password/route.ts | 18 + 6 files changed, 1050 insertions(+), 332 deletions(-) create mode 100644 app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile-layout.tsx create mode 100644 app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile.module.scss create mode 100644 app/api/auth/change-password/route.ts diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/orgProfile.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/orgProfile.tsx index a0299b9c..9c889528 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/orgProfile.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/orgProfile.tsx @@ -1,16 +1,33 @@ +'use client'; + import React from 'react'; import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; import { ApiOrganizationOrganizationTypesEnum, + OrganizationDataDocument, + OrganizationInfoDocument, OrganizationInputPartial, } from '@/gql/generated/graphql'; import { useOrganizationTypes } from '@/hooks/useOrganizationTypes'; -import { useMutation } from '@tanstack/react-query'; -import { Button, DropZone, Select, Text, TextField, toast } from 'opub-ui'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + AlertDialog, + Button, + SectionCard, + Select, + TextField, + toast, +} from 'opub-ui'; import { DashboardLogo, useDashboardStore } from '@/config/store'; import { GraphQL } from '@/lib/api'; +import { + ContributionCard, + ProfilePictureField, + ProfileSaveButton, + ProfileShell, +} from './profile-layout'; const organizationUpdateMutation = graphql(` mutation updateOrganization($input: OrganizationInputPartial!) { @@ -38,17 +55,19 @@ const organizationUpdateMutation = graphql(` } `); +type FormErrors = Partial>; + const OrgProfile = () => { const params = useParams<{ entitySlug: string; entityType: string }>(); const router = useRouter(); const { setEntityDetails, entityDetails } = useDashboardStore(); - const { organizationTypes } = useOrganizationTypes(); + const saved = entityDetails?.organizations?.[0]; const initialFormData = { name: '', contactEmail: '', - organizationTypes: ApiOrganizationOrganizationTypesEnum.StateGovernment, // or whichever is most appropriate + organizationTypes: ApiOrganizationOrganizationTypesEnum.StateGovernment, homepage: '', description: '', logo: null as File | DashboardLogo | null, @@ -60,39 +79,67 @@ const OrgProfile = () => { }; const [formData, setFormData] = React.useState(initialFormData); + const [errors, setErrors] = React.useState({}); + const [discardOpen, setDiscardOpen] = React.useState(false); const [prevEntityDetails, setPrevEntityDetails] = React.useState< typeof entityDetails | undefined >(undefined); + if (entityDetails !== prevEntityDetails) { setPrevEntityDetails(entityDetails); - if (entityDetails && entityDetails.organizations) { - const org = entityDetails.organizations[0]; - if (org) { - setFormData({ - name: org.name, - contactEmail: org.contactEmail ?? '', - organizationTypes: - typeof org.organizationTypes === 'string' - ? (org.organizationTypes as ApiOrganizationOrganizationTypesEnum) - : ApiOrganizationOrganizationTypesEnum.StateGovernment, - homepage: org.homepage ?? '', - description: org.description ?? '', - logo: org.logo ?? null, - id: org.id, - linkedinProfile: org.linkedinProfile ?? '', - githubProfile: org.githubProfile ?? '', - twitterProfile: org.twitterProfile ?? '', - location: org.location ?? '', - }); - } + const org = entityDetails?.organizations?.[0]; + if (org) { + setFormData({ + name: org.name, + contactEmail: org.contactEmail ?? '', + organizationTypes: + typeof org.organizationTypes === 'string' + ? (org.organizationTypes as ApiOrganizationOrganizationTypesEnum) + : ApiOrganizationOrganizationTypesEnum.StateGovernment, + homepage: org.homepage ?? '', + description: org.description ?? '', + logo: org.logo ?? null, + id: org.id, + linkedinProfile: org.linkedinProfile ?? '', + githubProfile: org.githubProfile ?? '', + twitterProfile: org.twitterProfile ?? '', + location: org.location ?? '', + }); + setErrors({}); } } - const { mutate } = useMutation( + const organizationId = saved?.id; + const contributionQuery = useQuery( + ['org_profile_contributions', organizationId], + () => + GraphQL( + OrganizationDataDocument, + { [params.entityType]: params.entitySlug }, + { id: organizationId ?? '' } + ), + { enabled: Boolean(organizationId) } + ); + const sectorQuery = useQuery( + ['org_profile_sectors', organizationId], + () => + GraphQL( + OrganizationInfoDocument, + { [params.entityType]: params.entitySlug }, + { organizationId: organizationId ?? '' } + ), + { enabled: Boolean(organizationId) } + ); + + const { mutate, isLoading } = useMutation( (input: { input: OrganizationInputPartial }) => - GraphQL(organizationUpdateMutation, { - [params.entityType]: params.entitySlug, - }, input), + GraphQL( + organizationUpdateMutation, + { + [params.entityType]: params.entitySlug, + }, + input + ), { onSuccess: (res) => { toast('Organization updated successfully'); @@ -113,26 +160,28 @@ const OrgProfile = () => { twitterProfile: org.twitterProfile ?? '', location: org.location ?? '', }); + setErrors({}); setEntityDetails({ organizations: [ { - id: formData.id, - name: formData.name, - contactEmail: formData.contactEmail, - organizationTypes: formData.organizationTypes, - homepage: formData.homepage, - description: formData.description, - logo: formData.logo, - linkedinProfile: formData.linkedinProfile, - githubProfile: formData.githubProfile, - twitterProfile: formData.twitterProfile, - location: formData.location, + id: org.id, + name: org.name, + contactEmail: org.contactEmail ?? '', + organizationTypes: org.organizationTypes, + homepage: org.homepage, + description: org.description, + logo: org.logo ?? null, + linkedinProfile: org.linkedinProfile ?? '', + githubProfile: org.githubProfile ?? '', + twitterProfile: org.twitterProfile ?? '', + location: org.location ?? '', }, ], }); if (org.slug && org.slug !== params.entitySlug) { - const newPath = `/dashboard/${params.entityType}/${org.slug}/profile`; - router.replace(newPath); + router.replace( + `/dashboard/${params.entityType}/${org.slug}/profile` + ); } }, onError: (error: unknown) => { @@ -143,162 +192,280 @@ const OrgProfile = () => { } ); - const handleSave = () => { - const formValidation = - formData.name && - formData.contactEmail && - formData.description && - formData.logo; + const hasUnsavedChanges = saved + ? formData.name.trim() !== saved.name || + formData.description.trim() !== (saved.description ?? '') || + formData.homepage.trim() !== (saved.homepage ?? '') || + formData.location.trim() !== (saved.location ?? '') || + formData.githubProfile.trim() !== (saved.githubProfile ?? '') || + formData.linkedinProfile.trim() !== (saved.linkedinProfile ?? '') || + formData.twitterProfile.trim() !== (saved.twitterProfile ?? '') || + formData.organizationTypes !== + (typeof saved.organizationTypes === 'string' + ? saved.organizationTypes + : ApiOrganizationOrganizationTypesEnum.StateGovernment) || + formData.logo instanceof File + : false; - if (!formValidation) { - toast('Please fill all the required fields'); + const resetForm = () => { + if (!saved) return; + setFormData({ + name: saved.name, + contactEmail: saved.contactEmail ?? '', + organizationTypes: + typeof saved.organizationTypes === 'string' + ? (saved.organizationTypes as ApiOrganizationOrganizationTypesEnum) + : ApiOrganizationOrganizationTypesEnum.StateGovernment, + homepage: saved.homepage ?? '', + description: saved.description ?? '', + logo: saved.logo ?? null, + id: saved.id, + linkedinProfile: saved.linkedinProfile ?? '', + githubProfile: saved.githubProfile ?? '', + twitterProfile: saved.twitterProfile ?? '', + location: saved.location ?? '', + }); + setErrors({}); + }; + + const clearError = (field: keyof FormErrors) => { + setErrors((prev) => ({ ...prev, [field]: undefined })); + }; + + const handleSave = () => { + const nextErrors: FormErrors = {}; + if (!formData.name.trim()) nextErrors.name = 'Enter the organization name.'; + if (!formData.description.trim()) { + nextErrors.description = 'Enter a description.'; + } + if (!formData.logo) nextErrors.logo = 'Add an organization logo.'; + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); return; - } else { - const inputData: OrganizationInputPartial = { - name: formData.name, - contactEmail: formData.contactEmail, - organizationTypes: formData.organizationTypes, - homepage: formData.homepage, - description: formData.description, - id: formData.id, - linkedinProfile: formData.linkedinProfile, - githubProfile: formData.githubProfile, - twitterProfile: formData.twitterProfile, - location: formData.location, - }; + } - // Only add logo if it has changed - if (formData.logo instanceof File) { - inputData.logo = formData.logo; - } + const inputData: OrganizationInputPartial = { + name: formData.name.trim(), + contactEmail: formData.contactEmail, + organizationTypes: formData.organizationTypes, + homepage: formData.homepage.trim(), + description: formData.description.trim(), + id: formData.id, + linkedinProfile: formData.linkedinProfile.trim(), + githubProfile: formData.githubProfile.trim(), + twitterProfile: formData.twitterProfile.trim(), + location: formData.location.trim(), + }; - mutate({ input: inputData }); + if (formData.logo instanceof File) { + inputData.logo = formData.logo; } + + mutate({ input: inputData }); }; + const areas = (sectorQuery.data?.organizationContributedSectors ?? []).map( + (sector) => sector.name + ); + return ( -
-
- My Organization Profile -
-
-
-
+ <> + + + + Save changes + + + } + > + +
+
+ { + setFormData({ ...formData, name: value }); + clearError('name'); + }} + /> + undefined} + /> +
+
+ - setFormData({ - ...formData, - organizationTypes: e as ApiOrganizationOrganizationTypesEnum, - }) - } - options={organizationTypes} + name="description" + label="Description" + requiredIndicator + multiline={4} + value={formData.description} + error={errors.description} + onChange={(value) => { + setFormData({ ...formData, description: value }); + clearError('description'); + }} /> +
+
-
+ + + +
setFormData({ ...formData, homepage: e })} + placeholder="https://github.com/username" + value={formData.githubProfile} + onChange={(value) => + setFormData({ ...formData, githubProfile: value }) + } /> -
-
-
-
setFormData({ ...formData, linkedinProfile: e })} - /> -
- -
- setFormData({ ...formData, githubProfile: e })} + placeholder="https://linkedin.com/in/username" + value={formData.linkedinProfile} + onChange={(value) => + setFormData({ ...formData, linkedinProfile: value }) + } /> -
-
-
-
setFormData({ ...formData, twitterProfile: e })} + onChange={(value) => + setFormData({ ...formData, twitterProfile: value }) + } />
+ -
- setFormData({ ...formData, location: e })} - /> -
-
-
-
- setFormData({ ...formData, description: e })} - /> -
-
- setFormData({ ...formData, logo: e[0] })} - name={'Logo'} - > - - -
-
- -
-
+ + + + + resetForm(), + }} + secondaryActions={[{ content: 'Stay' }]} + > + You have unsaved changes. Are you sure you want to leave? + + + ); }; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/page.tsx index 56e095e0..2e91a48f 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/page.tsx @@ -10,7 +10,7 @@ const Profile = () => { const path = usePathname(); return ( -
+
{path.includes('self') ? : }
); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile-layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile-layout.tsx new file mode 100644 index 00000000..471f4f8e --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile-layout.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useEffect, useMemo, useRef } from 'react'; +import { IconUser } from '@tabler/icons-react'; +import { Button, Icon, SectionCard, Tag, Text, toast } from 'opub-ui'; + +import styles from './profile.module.scss'; + +export const BIO_MAX_LENGTH = 500; + +export function normalizeProfileUrl(value: string) { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function isProfileUrl(value: string) { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +const MAX_PROFILE_IMAGE_BYTES = 5 * 1024 * 1024; +const PROFILE_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png']; + +type ProfileImage = File | { url?: string | null; name?: string | null } | null; + +export function ProfileSaveButton(props: React.ComponentProps) { + return ( + + + +
+
+ ); +} + +export function ContributionCard({ + datasetCount, + useCaseCount, + areas, + loading, + failed, +}: { + datasetCount?: number; + useCaseCount?: number; + areas: string[]; + loading: boolean; + failed: boolean; +}) { + return ( + + {loading ? ( + + Loading your published contributions. + + ) : failed ? ( + + Couldn't load your contributions. + + ) : ( +
+ + + {datasetCount ?? 0} + + {' Datasets · '} + + {useCaseCount ?? 0} + + {' Use cases'} + + {areas.length > 0 ? ( +
+ + AREAS OF CONTRIBUTION + +
+ {areas.map((area) => ( + + {area} + + ))} +
+
+ ) : null} +
+ )} +
+ ); +} + +function imageSrc(value: ProfileImage) { + if (!value) return null; + if (value instanceof File) return URL.createObjectURL(value); + const raw = value.url; + if (!raw) return null; + if (raw.startsWith('http') || raw.startsWith('blob:')) return raw; + return `${process.env.NEXT_PUBLIC_BACKEND_URL}/${raw}`; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile.module.scss new file mode 100644 index 00000000..20e542b5 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/profile.module.scss @@ -0,0 +1,11 @@ +.profile { + --text-highlight: var(--blue-primary-color); +} + +.saveChanges { + display: inline-flex; + --action-primary-basic-default: var(--blue-primary-color); + --action-primary-basic-hovered: var(--blue-primary-text); + --action-primary-basic-pressed: var(--blue-primary-text); + --text-highlight: var(--blue-primary-color); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx index 2bd867ff..30f713ae 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx @@ -3,12 +3,35 @@ import React from 'react'; import { useParams } from 'next/navigation'; import { graphql } from '@/gql'; -import { UpdateUserInput } from '@/gql/generated/graphql'; -import { useMutation } from '@tanstack/react-query'; -import { Button, DropZone, Text, TextField, toast } from 'opub-ui'; +import { + SectorInfoDocument, + UpdateUserInput, + UserDataDocument, +} from '@/gql/generated/graphql'; +import { IconArrowRight, IconKey, IconShieldCheck } from '@tabler/icons-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + AlertDialog, + Badge, + Button, + Icon, + SectionCard, + Text, + TextField, + toast, +} from 'opub-ui'; -import { DashboardLogo, useDashboardStore } from '@/config/store'; +import { useDashboardStore } from '@/config/store'; import { GraphQL } from '@/lib/api'; +import { + BIO_MAX_LENGTH, + ContributionCard, + isProfileUrl, + normalizeProfileUrl, + ProfilePictureField, + ProfileSaveButton, + ProfileShell, +} from './profile-layout'; const updateUserMutation = graphql(` mutation updateUser($input: UpdateUserInput!) { @@ -35,37 +58,38 @@ const updateUserMutation = graphql(` } `); -const githubRegex = - /^https:\/\/github\.com\/[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/; -const linkedinRegex = - /^https:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9-]+\/?$/; -const twitterRegex = - /^https:\/\/(?:www\.)?(?:twitter\.com|x\.com)\/[a-zA-Z0-9_]+\/?$/; - -const prettyField = (f: string) => { - switch (f) { +const prettyField = (field: string) => { + switch (field) { case 'github_profile': return 'GitHub URL'; case 'linkedin_profile': return 'LinkedIn URL'; case 'twitter_profile': - return 'Twitter URL'; + return 'X URL'; + case 'profile_picture': + return 'Profile picture'; default: - return f.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + return field.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } }; +type SocialField = 'githubProfile' | 'linkedinProfile' | 'twitterProfile'; +type FormErrors = Partial< + Record<'firstName' | 'lastName' | SocialField, string> +>; + const UserProfile = () => { const params = useParams<{ entityType: string; entitySlug: string }>(); - const { setUserDetails, userDetails } = useDashboardStore(); + const saved = userDetails?.me; const initialFormData = { firstName: '', lastName: '', email: '', bio: '', - profilePicture: null as File | DashboardLogo | null, + profilePicture: null as + File | { url?: string | null; name?: string | null } | null, githubProfile: '', linkedinProfile: '', twitterProfile: '', @@ -73,74 +97,116 @@ const UserProfile = () => { }; const [formData, setFormData] = React.useState(initialFormData); + const [errors, setErrors] = React.useState({}); + const [discardOpen, setDiscardOpen] = React.useState(false); + const [deleteOpen, setDeleteOpen] = React.useState(false); const [prevUserDetails, setPrevUserDetails] = React.useState< typeof userDetails | undefined >(undefined); + if (userDetails !== prevUserDetails) { setPrevUserDetails(userDetails); - if (userDetails && userDetails?.me) { + if (userDetails?.me) { setFormData({ - firstName: userDetails?.me?.firstName ?? '', - lastName: userDetails?.me?.lastName ?? '', - email: userDetails?.me?.email ?? '', - bio: userDetails?.me?.bio ?? '', - profilePicture: userDetails?.me?.profilePicture ?? null, - githubProfile: userDetails?.me?.githubProfile ?? '', - linkedinProfile: userDetails?.me?.linkedinProfile ?? '', - twitterProfile: userDetails?.me?.twitterProfile ?? '', - location: userDetails?.me?.location ?? '', + firstName: userDetails.me.firstName ?? '', + lastName: userDetails.me.lastName ?? '', + email: userDetails.me.email ?? '', + bio: userDetails.me.bio ?? '', + profilePicture: userDetails.me.profilePicture ?? null, + githubProfile: userDetails.me.githubProfile ?? '', + linkedinProfile: userDetails.me.linkedinProfile ?? '', + twitterProfile: userDetails.me.twitterProfile ?? '', + location: userDetails.me.location ?? '', }); + setErrors({}); } } - const { mutate } = useMutation( - (input: { input: UpdateUserInput }) => + const userId = saved?.id; + const contributionQuery = useQuery( + ['profile_contributions', userId], + () => + GraphQL( + UserDataDocument, + { [params.entityType]: params.entitySlug }, + { userId: userId ?? '' } + ), + { enabled: Boolean(userId) } + ); + const sectorQuery = useQuery( + ['profile_sectors', userId], + () => GraphQL( - updateUserMutation, + SectorInfoDocument, { [params.entityType]: params.entitySlug }, - input + { userId: userId ?? '' } ), + { enabled: Boolean(userId) } + ); + + const { mutate, isLoading } = useMutation( + async (payload: { input: UpdateUserInput; picture: File | null }) => { + const headers = { [params.entityType]: params.entitySlug }; + const saved = await GraphQL(updateUserMutation, headers, { + input: payload.input, + }); + if (!(payload.picture instanceof File)) { + return { result: saved, pictureFailed: false }; + } + + try { + const withPicture = await GraphQL(updateUserMutation, headers, { + input: { profilePicture: payload.picture }, + }); + return { result: withPicture, pictureFailed: false }; + } catch { + return { result: saved, pictureFailed: true }; + } + }, { - onSuccess: (res) => { - toast('User details updated successfully'); + onSuccess: ({ result, pictureFailed }) => { + toast( + pictureFailed + ? 'Profile saved, but the picture could not be updated.' + : 'User details updated successfully' + ); + const updated = result.updateUser; setFormData({ - firstName: res.updateUser.firstName, - lastName: res.updateUser.lastName, - email: res.updateUser.email, - bio: res.updateUser.bio ?? '', - profilePicture: res.updateUser.profilePicture ?? null, - githubProfile: res.updateUser.githubProfile ?? '', - linkedinProfile: res.updateUser.linkedinProfile ?? '', - twitterProfile: res.updateUser.twitterProfile ?? '', - location: res.updateUser.location ?? '', + firstName: updated.firstName, + lastName: updated.lastName, + email: updated.email, + bio: updated.bio ?? '', + profilePicture: updated.profilePicture ?? null, + githubProfile: updated.githubProfile ?? '', + linkedinProfile: updated.linkedinProfile ?? '', + twitterProfile: updated.twitterProfile ?? '', + location: updated.location ?? '', }); + setErrors({}); setUserDetails({ ...userDetails, - me: res.updateUser, + me: updated, }); }, - onError: (error: unknown) => { const message = error instanceof Error ? error.message : undefined; if (typeof message === 'string') { - - // Try to extract field errors const tryField = (field: string) => { - const m = message.match( + const match = message.match( new RegExp(`'${field}'\\s*:\\s*\\['([^']+)'\\]`) ); - if (m?.[1]) { - const prettyName = prettyField(field); - const errorMsg = `${prettyName}: ${m[1]}`; - toast.error(errorMsg); + if (match?.[1]) { + toast.error(`${prettyField(field)}: ${match[1]}`); } - return Boolean(m?.[1]); + return Boolean(match?.[1]); }; const anyMatched = tryField('github_profile') || tryField('linkedin_profile') || - tryField('twitter_profile'); + tryField('twitter_profile') || + tryField('profile_picture') || + tryField('location'); if (!anyMatched) { toast.error(`Error: ${message}`); @@ -153,159 +219,383 @@ const UserProfile = () => { } ); - const handleSave = () => { - // Create mutation input with only changed fields - const formValidation = - formData.firstName && - formData.lastName && - formData.email && - formData.bio && - formData.profilePicture; - - if (!formValidation) { - toast('Please fill all the required fields'); + const hasUnsavedChanges = saved + ? formData.firstName.trim() !== (saved.firstName ?? '') || + formData.lastName.trim() !== (saved.lastName ?? '') || + formData.location.trim() !== (saved.location ?? '') || + formData.bio.trim() !== (saved.bio ?? '') || + formData.githubProfile.trim() !== (saved.githubProfile ?? '') || + formData.linkedinProfile.trim() !== (saved.linkedinProfile ?? '') || + formData.twitterProfile.trim() !== (saved.twitterProfile ?? '') || + formData.profilePicture instanceof File + : false; + + const resetForm = () => { + if (!saved) return; + setFormData({ + firstName: saved.firstName ?? '', + lastName: saved.lastName ?? '', + email: saved.email ?? '', + bio: saved.bio ?? '', + profilePicture: saved.profilePicture ?? null, + githubProfile: saved.githubProfile ?? '', + linkedinProfile: saved.linkedinProfile ?? '', + twitterProfile: saved.twitterProfile ?? '', + location: saved.location ?? '', + }); + setErrors({}); + }; + + const clearError = (field: keyof FormErrors) => { + setErrors((prev) => ({ ...prev, [field]: undefined })); + }; + + const handleCancel = () => { + if (!hasUnsavedChanges) { + resetForm(); return; } - if (formData.githubProfile && !githubRegex.test(formData.githubProfile)) { - toast.error('GitHub URL: Enter a valid URL.'); - return; + setDiscardOpen(true); + }; + + const handleSave = () => { + const nextErrors: FormErrors = {}; + if (!formData.firstName.trim()) { + nextErrors.firstName = 'Enter your first name.'; } - if ( - formData.linkedinProfile && - !linkedinRegex.test(formData.linkedinProfile) - ) { - toast.error('LinkedIn URL: Enter a valid URL.'); - return; + if (!formData.lastName.trim()) { + nextErrors.lastName = 'Enter your last name.'; + } + const githubProfile = normalizeProfileUrl(formData.githubProfile); + const linkedinProfile = normalizeProfileUrl(formData.linkedinProfile); + const twitterProfile = normalizeProfileUrl(formData.twitterProfile); + if (githubProfile && !isProfileUrl(githubProfile)) { + nextErrors.githubProfile = 'Enter a valid GitHub profile URL.'; } - if ( - formData.twitterProfile && - !twitterRegex.test(formData.twitterProfile) - ) { - toast.error('Twitter URL: Enter a valid URL.'); + if (linkedinProfile && !isProfileUrl(linkedinProfile)) { + nextErrors.linkedinProfile = 'Enter a valid LinkedIn profile URL.'; + } + if (twitterProfile && !isProfileUrl(twitterProfile)) { + nextErrors.twitterProfile = 'Enter a valid X profile URL.'; + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + toast.error('Check the highlighted profile fields.'); return; } const inputData: UpdateUserInput = { - firstName: formData.firstName, - lastName: formData.lastName, - bio: formData.bio, + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + bio: formData.bio.trim(), email: formData.email, - githubProfile: formData.githubProfile, - linkedinProfile: formData.linkedinProfile, - twitterProfile: formData.twitterProfile, - location: formData.location, + githubProfile, + linkedinProfile, + twitterProfile, + location: formData.location.trim(), }; - // Only add logo if it has changed - if (formData.profilePicture instanceof File) { - inputData.profilePicture = formData.profilePicture; + mutate({ + input: inputData, + picture: + formData.profilePicture instanceof File + ? formData.profilePicture + : null, + }); + }; + + const openChangePassword = async () => { + try { + const response = await fetch('/api/auth/change-password'); + const data = (await response.json()) as { url?: string }; + if (!data.url) { + toast.error('Could not open the Keycloak password page.'); + return; + } + window.location.assign(data.url); + } catch { + toast.error('Could not open the Keycloak password page.'); } - mutate({ input: inputData }); }; - return ( -
-
- My Profile -
-
-
-
-
-
- setFormData({ ...formData, firstName: e })} - /> -
-
- setFormData({ ...formData, lastName: e })} - /> -
-
+ const areas = (sectorQuery.data?.userContributedSectors ?? []).map( + (sector) => sector.name + ); -
+ return ( + <> + + + + Save changes + + + } + > + +
+
setFormData({ ...formData, email: e })} + name="firstName" + label="First Name" + requiredIndicator + value={formData.firstName} + error={errors.firstName} + onChange={(value) => { + setFormData({ ...formData, firstName: value }); + clearError('firstName'); + }} + /> + { + setFormData({ ...formData, lastName: value }); + clearError('lastName'); + }} />
-
+ undefined} + /> + + setFormData({ ...formData, location: value }) + } + /> +
+ + + +
+
setFormData({ ...formData, location: e })} + name="bio" + label="Bio" + multiline={4} + maxLength={BIO_MAX_LENGTH} + helpText="Briefly describe your role, expertise or areas of interest." + value={formData.bio} + onChange={(value) => + setFormData({ + ...formData, + bio: value.slice(0, BIO_MAX_LENGTH), + }) + } /> + + {formData.bio.length} / {BIO_MAX_LENGTH} +
+ { + if (file !== null) { + setFormData({ ...formData, profilePicture: file }); + return; + } + if (formData.profilePicture instanceof File) { + setFormData({ + ...formData, + profilePicture: saved?.profilePicture ?? null, + }); + return; + } + toast('Removing a saved profile picture isn’t available yet.'); + }} + />
-
+ + + +
setFormData({ ...formData, githubProfile: e })} + error={errors.githubProfile} + onChange={(value) => { + setFormData({ ...formData, githubProfile: value }); + clearError('githubProfile'); + }} /> setFormData({ ...formData, linkedinProfile: e })} + error={errors.linkedinProfile} + onChange={(value) => { + setFormData({ ...formData, linkedinProfile: value }); + clearError('linkedinProfile'); + }} /> setFormData({ ...formData, twitterProfile: e })} + error={errors.twitterProfile} + onChange={(value) => { + setFormData({ ...formData, twitterProfile: value }); + clearError('twitterProfile'); + }} />
-
-
-
- setFormData({ ...formData, bio: e })} - /> + + + + + +
+
+
+ + Email + + + {formData.email || '—'} + +
+ + Account email + +
+
+
+ +
+ + Sign-in method + + + Keycloak + +
+
+ Connected +
+
+
+ +
+ + Password + + + •••••••• + +
+
+ +
-
- setFormData({ ...formData, profilePicture: e[0] })} - name={'Profile Picture'} - > - - + + + Danger zone + } + > +
+
+ + Delete account + + + Permanently delete your CivicDataSpace account and account + information. + +
+
-
+
+ + + + resetForm(), + }} + secondaryActions={[{ content: 'Stay' }]} + > + You have unsaved changes. Are you sure you want to leave? + + - -
-
+ + + Permanently deleting your CivicDataSpace account isn’t available from + this page yet. + + + ); }; diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts new file mode 100644 index 00000000..12e3d81d --- /dev/null +++ b/app/api/auth/change-password/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { env } from '@/env'; +import { getServerSession } from 'next-auth'; + +import { authOptions } from '../[...nextauth]/options'; + +export async function GET() { + const session = await getServerSession(authOptions); + + if (!session || session.error === 'RefreshAccessTokenError') { + return NextResponse.json({ url: `${env.NEXTAUTH_URL}/login` }); + } + + const issuer = env.AUTH_ISSUER.replace(/\/$/, ''); + return NextResponse.json({ + url: `${issuer}/account/account-security/signing-in`, + }); +}