Files
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
bf348ee209 refactor: migrate Icon to component icons from @coss/ui/icons (#27458)
* feat: add @coss/ui/icons package and migrate static icon usages

Create a new icon system in @coss/ui/icons that wraps lucide-react icons
with controlled props (size and className only). Migrate 44 component
files from <Icon name="xxx" /> to <XxxIcon /> pattern.

This is Phase 1 of the icon migration, covering only static icon names.
Dynamic icon usages (e.g., name={variable}) are skipped for now.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate more static icons to @coss/ui/icons (batch 2)

Migrated 18 files from <Icon name="xxx" /> to <XxxIcon /> pattern:
- Event meta, insights, shell, user table bulk actions
- Webhooks, blocklist, form-builder, form components

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate more static icons to @coss/ui/icons (batch 3)

Migrated 7 files:
- CancelBooking, AddGuestsDialog, ChargeCardDialog
- ReassignDialog, RescheduleDialog
- Categories, Slider

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate more static icons to @coss/ui/icons (batch 4)

Migrated 6 files:
- Organizations: AboutOrganizationForm, PaymentStatusView, profile
- Workflows: CallDetailsSheet, SkeletonLoaderList, TimeTimeUnitInput

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate more static icons to @coss/ui/icons (batch 5)

Migrated 4 files:
- TwoFactorModalHeader
- App setups: alby, make, btcpayserver

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 6)

- confirmDialog.tsx: clock icon
- ics-feedcalendar/Setup.tsx: trash, plus icons
- hitpay/Setup.tsx: info icon
- paypal/Setup.tsx: circle-alert icons

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 7)

- webhook-edit-view.tsx: external-link icon
- webhook-new-view.tsx: external-link icon
- UserDropdown.tsx: chevron-down, user, settings, moon, map, circle-help, download, blocks, log-out icons
- TopNav.tsx: settings icon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 8)

- EmailInviteForm.tsx: x icon
- InviteOptions.tsx: mail, upload, link icons
- SkeletonLoader.tsx: search, clock, user icons
- Embed.tsx: sun, arrow-left icons
- CalendarSwitch.tsx: arrow-left, rotate-cw icons

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 9)

- apps-view.tsx: search icon
- categories-view.tsx: arrow-left, arrow-right icons
- DatePicker.tsx: calendar icon
- EditLocationDialog.tsx: map-pin icon
- AppPage.tsx: circle-alert, book-open, external-link, mail, file, shield, flag icons

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 10)

- LicenseSelection.tsx: loader, check icons
- AppConnectionItem.tsx: circle-alert, arrow-right icons
- oauth-client-form/index.tsx: info icons (x2)
- auth/error/page.tsx: x icon
- troubleshoot/layout.tsx: loader icon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 11)

- UpgradeTipWrapper.tsx: users, refresh-ccw, user-plus icons
- PbacOptInModal.tsx: shield-check, lock, users, shield icons
- AdvancedPermissionGroup.tsx: chevron-right, info icons

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 12)

- AdminAppsList.tsx: pencil icon
- AvailableTimes.tsx: calendar-x-2 icon
- BookingDetailsSheet.tsx: external-link (x2), repeat icons
- BookingCalendarContainer.tsx: chevron-left, chevron-right icons

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 13)

- videos-single-view.tsx: chevron-right icon
- TroubleshooterSidebar.tsx: arrow-left icon
- RuleBuilder.tsx: filter icon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 14)

- FieldMappingBuilder.tsx: arrow-right, link icons
- IntegrationAttributeSyncCard.tsx: key icon
- onboarding-view.tsx: loader icon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static icons to @coss/ui/icons (batch 15)

- team-profile-view.tsx: copy icon
- DateRangeFilter.tsx: check icon
- ColumnVisibilityButton.tsx: check icon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static Icon to @coss/ui/icons (batch 16)

- EventWorkflowsTab.tsx: lock
- event-types-listing-view.tsx: search
- onboarding-migrate-members-browser-view.tsx: arrow-left, arrow-right, rotate-cw, lock, ellipsis-vertical, check, user-plus
- onboarding-migrate-teams-browser-view.tsx: arrow-left, arrow-right, rotate-cw, lock, ellipsis-vertical, check, arrow-right, users
- EventAvailabilityTab.tsx: globe, user
- onboarding-teams-browser-view.tsx: arrow-left, arrow-right, rotate-cw, lock, ellipsis-vertical, check, arrow-right, users
- FormBuilder.tsx: mail, phone, arrow-up, arrow-down
- AIEventController.tsx: info
- EventTeamAssignmentTab.tsx: x

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static Icon to @coss/ui/icons (batch 17)

- EventAdvancedTab.tsx: pencil, info
- HostLocations.tsx: triangle-alert, loader
- EventTypeLayout.tsx: loader
- Locations.tsx: x, corner-down-right, check
- EditWeightsForAllTeamMembers.tsx: upload, search, info, chevron-down
- WorkflowPage.tsx: pencil

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static Icon to @coss/ui/icons (batch 18)

- TestPhoneCallDialog.tsx: phone
- WorkflowListPage.tsx: link
- UsersTable.tsx: building, lock
- TeamList.tsx: building, paintbrush, chart-line, user-plus, users, pencil
- bookings-single-view.tsx: chevron-left, check, calendar, x, external-link

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate static Icon to @coss/ui/icons (batch 19)

- TeamsListing.tsx: users, refresh-ccw, user-plus, mail, video, eye-off, info
- ConnectionInfo.tsx: clipboard
- IntercomContactForm.tsx: message-circle, loader, send

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: migrate static Icon to @coss/ui/icons in teams components

- EventTypesList.tsx: check → CheckIcon
- MemberInvitationModal.tsx: user, users, building → UserIcon, UsersIcon, BuildingIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: migrate static Icon to @coss/ui/icons in workflow components

- ImportNumberDialog.tsx: info → InfoIcon
- VoiceSelector.tsx: user → UserIcon
- IncomingCallsTab.tsx: info, phone-incoming → InfoIcon, PhoneIncomingIcon
- PhoneNumberTab.tsx: loader, phone → LoaderIcon, PhoneIcon
- VoiceSelectionDialog.tsx: pause, play, user → PauseIcon, PlayIcon, UserIcon
- WebCallDialog.tsx: loader, phone, phone-off, triangle-alert, trash, message-circle → new icons (kept Icon import for dynamic mic icon)
- WorkflowDetailsPage.tsx: zap, arrow-right → ZapIcon, ArrowRightIcon
- AddActionDialog.tsx: info → InfoIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: migrate static Icon to @coss/ui/icons in WorkflowStepContainer

- info → InfoIcon (2 instances)
- phone → PhoneIcon (2 instances)
- circle-help → CircleHelpIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: migrate static Icon to @coss/ui/icons in data-table and org components

- attributes-list-view.tsx: tags → TagsIcon
- AddFilterButton.tsx: plus, eye-off → PlusIcon, EyeOffIcon
- BaseSelectFilterOptions.tsx: check → CheckIcon
- DataTable.tsx: arrow-up, arrow-down, check, chevrons-up-down, eye-off → new icons
- booking-dry-run-success-view.tsx: check → CheckIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: migrate static Icon to @coss/ui/icons in username and routing components

- UsernameTextfield.tsx: check → CheckIcon
- PremiumTextfield.tsx: check, external-link → CheckIcon, ExternalLinkIcon (kept Icon for star with fill prop)
- Header.tsx (routing-forms): pencil, menu, waypoints → PencilIcon, MenuIcon, WaypointsIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate Icon to component icons in routing-forms pages

Batch 25: Migrate static Icon usages to @coss/ui/icons components.

Files changed:
- Forms.tsx: file-text, shuffle, chart-bar, circle-check, mail, download → FileTextIcon, ShuffleIcon, ChartBarIcon, CircleCheckIcon, MailIcon, DownloadIcon
- FormEdit.tsx: chevron-down, menu → ChevronDownIcon, MenuIcon
- IncompleteBooking.tsx: globe → GlobeIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate Icon to component icons in UI package

Batch 26: Migrate static Icon usages to @coss/ui/icons components.

Files changed:
- ProgressToast.tsx: file-down, x → FileDownIcon, XIcon
- showToast.tsx: check, info, x → CheckIcon, InfoIcon, XIcon
- InfoBadge.tsx: info → InfoIcon
- MeetingTimeInTimezones.tsx: globe → GlobeIcon
- InputError.tsx: info → InfoIcon
- ConfirmationDialogContent.tsx: circle-alert, check → CircleAlertIcon, CheckIcon
- CalendarSwitch.tsx: arrow-left, rotate-cw → ArrowLeftIcon, RotateCwIcon
- ArrowButton.tsx: arrow-up, arrow-down → ArrowUpIcon, ArrowDownIcon
- FileUploader.tsx: upload → UploadIcon (kept Icon for dynamic)
- Checkbox.tsx: check → CheckIcon
- EditableHeading.tsx: pencil → PencilIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate Icon to component icons in UI and app-store packages

Batch 27: Migrate static Icon usages to @coss/ui/icons components.

Files changed:
- MultiEmail.tsx: x → XIcon (converted width to size)
- AppListCard.tsx: circle-alert → CircleAlertIcon
- SplitButton.tsx: chevron-down → ChevronDownIcon (kept Icon for dynamic)
- Calendar.tsx: chevron-left, chevron-right → ChevronLeftIcon, ChevronRightIcon
- AppDependencyComponent.tsx: check, circle-x, arrow-right → CheckIcon, CircleXIcon, ArrowRightIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate Icon to component icons in form input components

Batch 28: Migrate static Icon usages to @coss/ui/icons components.

Files changed:
- TextField.tsx: info → InfoIcon (kept Icon for x with onClick)
- Input.tsx: eye, eye-off, search → EyeIcon, EyeOffIcon, SearchIcon

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: use @coss/ui/src/icons in packages with legacy moduleResolution

Packages like packages/ui and packages/app-store use a legacy moduleResolution
setting that doesn't support subpath exports. This changes the import path from
@coss/ui/icons to @coss/ui/src/icons for these packages.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate Icon to component icons in UI form and editor components

Migrates static Icon usages:
- MultiOptionInput: x (2x), arrow-up, arrow-down
- components (select): check
- AddVariablesDropdown: chevron-down (3x)
- ToolbarPlugin: chevron-down

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: migrate Icon to component icons in settings and platform atoms

Migrates static Icon usages:
- SettingsLayoutAppDirClient: chevron-down (2x), chevron-right (2x), arrow-left
- platform/atoms dialog: x
- platform/atoms toast: x

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: resolve @coss/ui/icons module resolution for legacy moduleResolution

Add typesVersions to @coss/ui package.json to help TypeScript resolve
subpath exports even with legacy moduleResolution: "node" setting.

Change imports from @coss/ui/src/icons to @coss/ui/icons to avoid
double-src path issue when Next.js applies import maps.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: localize 'Select field type' fallback label in FormEdit

Address Cubic AI review feedback (confidence 9/10) by using t() for
the hardcoded 'Select field type' string to ensure proper i18n support.

Co-Authored-By: unknown <>

* fix: add typesVersions wildcards for @coss/ui subpath imports

Add lib/*, components/*, and hooks/* to typesVersions to support
legacy moduleResolution with all package subpath patterns.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: resolve missing Icon references in settings and workflows

- Add Icon import for dynamic icon usages in SettingsLayoutAppDirClient
- Replace static Icon usages with component icons (InfoIcon, PhoneIcon)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: resolve icon component type errors

- Add onClick prop support to IconProps
- Fix size prop type (use number instead of string)
- Rename local MailOpenIcon to MailOpenIconWrapper to avoid shadowing

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: add data-testid to icon components for test compatibility

- Add testId parameter to createIcon function
- Auto-generate data-testid for all icons (e.g., "check-icon", "search-icon")
- Remove outdated mock for @calcom/ui/components/icon in tests

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Update package.json

* Update package.json

* fix: migrate Icon to CalendarDaysIcon in videos-single-view.tsx

Co-Authored-By: unknown <>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-12 14:30:27 +01:00

778 lines
28 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { revalidateSettingsProfile } from "app/cache/path/settings/my-account";
// eslint-disable-next-line no-restricted-imports
import { get, pick } from "lodash";
import { signOut, useSession } from "next-auth/react";
import type { BaseSyntheticEvent } from "react";
import React, { useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { emailSchema } from "@calcom/lib/emailSchema";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
import { IdentityProvider } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
import { Alert } from "@calcom/ui/components/alert";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogTrigger, DialogClose } from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
import { Form } from "@calcom/ui/components/form";
import { PasswordField } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { InfoIcon } from "@coss/ui/icons";
import { showToast } from "@calcom/ui/components/toast";
import { DisplayInfo } from "@calcom/web/modules/users/components/UserTable/EditSheet/DisplayInfo";
import TwoFactor from "@components/auth/TwoFactor";
import CustomEmailTextField from "@components/settings/CustomEmailTextField";
import SecondaryEmailConfirmModal from "@components/settings/SecondaryEmailConfirmModal";
import SecondaryEmailModal from "@components/settings/SecondaryEmailModal";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import type { TRPCClientErrorLike } from "@trpc/client";
import { CompanyEmailOrganizationBanner } from "./components/CompanyEmailOrganizationBanner";
interface DeleteAccountValues {
totpCode: string;
}
type Email = {
id: number;
email: string;
emailVerified: string | null;
emailPrimary: boolean;
};
export type FormValues = {
username: string;
avatarUrl: string | null;
name: string;
email: string;
bio: string;
secondaryEmails: Email[];
};
type Props = {
user: RouterOutputs["viewer"]["me"]["get"];
};
const ProfileView = ({ user }: Props) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const session = useSession();
const { update } = session;
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
utils.viewer.me.invalidate();
utils.viewer.me.shouldVerifyEmail.invalidate();
revalidateSettingsProfile();
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success");
} else {
showToast(t("settings_updated_successfully"), "success");
}
setTempFormValues(null);
},
onError: (e) => {
switch (e.message) {
// TODO: Add error codes.
case "email_already_used":
{
showToast(t(e.message), "error");
}
return;
default:
showToast(t("error_updating_settings"), "error");
}
},
});
const unlinkConnectedAccountMutation = trpc.viewer.loggedInViewerRouter.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (e) => {
showToast(t(e.message), "error");
},
});
const addSecondaryEmailMutation = trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
onSuccess: (res) => {
setShowSecondaryEmailModalOpen(false);
setNewlyAddedSecondaryEmail(res?.data?.email);
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (error) => {
setSecondaryEmailAddErrorMessage(error?.message || "");
},
});
const resendVerifyEmailMutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false);
const [tempFormValues, setTempFormValues] = useState<ExtendedFormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [showCreateAccountPasswordDialog, setShowCreateAccountPasswordDialog] = useState(false);
const [showAccountDisconnectWarning, setShowAccountDisconnectWarning] = useState(false);
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false);
const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState("");
const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState<undefined | string>(undefined);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const [isCompanyEmailAlertDismissed, setIsCompanyEmailAlertDismissed] = useState(false);
const form = useForm<DeleteAccountValues>();
const onDeleteMeSuccessMutation = async () => {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
showToast(t("Your account was deleted"), "success");
setHasDeleteErrors(false); // dismiss any open errors
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
signOut({ callbackUrl: "/auth/logout?survey=true" });
} else {
signOut({ callbackUrl: "/auth/logout" });
}
};
const confirmPasswordMutation = trpc.viewer.auth.verifyPassword.useMutation({
onSuccess() {
if (tempFormValues) updateProfileMutation.mutate(tempFormValues);
setConfirmPasswordOpen(false);
},
onError() {
setConfirmPasswordDeleteErrorMessage(t("incorrect_password"));
},
});
const onDeleteMeErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
setHasDeleteErrors(true);
setDeleteErrorMessage(errorMessages[error.message]);
};
const deleteMeMutation = trpc.viewer.me.deleteMe.useMutation({
onSuccess: onDeleteMeSuccessMutation,
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
});
const deleteMeWithoutPasswordMutation = trpc.viewer.me.deleteMeWithoutPassword.useMutation({
onSuccess: onDeleteMeSuccessMutation,
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
});
const isCALIdentityProvider = user?.identityProvider === IdentityProvider.CAL;
const onConfirmPassword = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
const password = passwordRef.current.value;
confirmPasswordMutation.mutate({ passwordInput: password });
};
const onConfirmButton = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (isCALIdentityProvider) {
const totpCode = form.getValues("totpCode");
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
} else {
deleteMeWithoutPasswordMutation.mutate();
}
};
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
e?.preventDefault();
if (isCALIdentityProvider) {
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
} else {
deleteMeWithoutPasswordMutation.mutate();
}
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const passwordRef = useRef<HTMLInputElement>(null!);
const errorMessages: { [key: string]: string } = {
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
const userEmail = user.email || "";
const defaultValues = {
username: user.username || "",
avatarUrl: user.avatarUrl,
name: user.name || "",
email: userEmail,
bio: user.bio || "",
// We add the primary email as the first item in the list
secondaryEmails: [
{
id: 0,
email: userEmail,
emailVerified: user.emailVerified?.toString() || null,
emailPrimary: true,
},
...(user.secondaryEmails || []).map((secondaryEmail) => ({
...secondaryEmail,
emailVerified: secondaryEmail.emailVerified?.toString() || null,
emailPrimary: false,
})),
],
};
// Check if user should see company email alert
const shouldShowCompanyEmailAlert =
!isCompanyEmailAlertDismissed &&
!session.data?.user?.org?.id &&
!user.organization?.id &&
userEmail &&
isCompanyEmail(userEmail);
return (
<SettingsHeader
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isPending={updateProfileMutation.isPending}
isFallbackImg={!user.avatarUrl}
user={user}
userOrganization={user.organization}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) {
setTempFormValues(values);
setConfirmPasswordOpen(true);
} else {
updateProfileMutation.mutate(values);
}
}}
handleAddSecondaryEmail={() => setShowSecondaryEmailModalOpen(true)}
handleResendVerifyEmail={(email) => {
resendVerifyEmailMutation.mutate({ email });
showToast(t("email_sent"), "success");
}}
handleAccountDisconnect={(values) => {
if (isCALIdentityProvider) return;
if (user?.passwordAdded) {
setTempFormValues(values);
setShowAccountDisconnectWarning(true);
return;
}
setShowCreateAccountPasswordDialog(true);
}}
extraField={
<div className="mt-6">
<UsernameAvailabilityField
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
}}
onErrorMutation={() => {
showToast(t("error_updating_settings"), "error");
}}
/>
</div>
}
isCALIdentityProvider={isCALIdentityProvider}
/>
{shouldShowCompanyEmailAlert && (
<div className="mt-6">
<CompanyEmailOrganizationBanner onDismissAction={() => setIsCompanyEmailAlertDismissed(true)} />
</div>
)}
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
</div>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon="trash-2">
{t("delete_account")}
</Button>
</DialogTrigger>
</SectionBottomActions>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
type="creation"
Icon="triangle-alert">
<>
<div className="mb-10">
<p className="text-subtle mb-4 text-sm">{t("delete_account_confirmation_message")}</p>
{isCALIdentityProvider && (
<PasswordField
data-testid="password"
name="password"
id="password"
autoComplete="current-password"
required
label="Password"
ref={passwordRef}
/>
)}
{user?.twoFactorEnabled && isCALIdentityProvider && (
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
<TwoFactor center={false} />
</Form>
)}
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
</div>
<DialogFooter showDivider>
<DialogClose />
<Button
color="destructive"
data-testid="delete-account-confirm"
onClick={(e) => onConfirmButton(e)}
loading={deleteMeMutation.isPending}>
{t("delete_my_account")}
</Button>
</DialogFooter>
</>
</DialogContent>
</Dialog>
{/* If changing email, confirm password */}
<Dialog open={confirmPasswordOpen} onOpenChange={setConfirmPasswordOpen}>
<DialogContent
title={t("confirm_password")}
description={t("confirm_password_change_email")}
type="creation"
Icon="triangle-alert">
<div className="mb-10">
<div className="mb-4 grid gap-2 md:grid-cols-2">
<div>
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("old_email_address")}
</span>
<p className="text-subtle leading-none">{user.email}</p>
</div>
<div>
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("new_email_address")}
</span>
<p className="text-subtle leading-none">{tempFormValues?.email}</p>
</div>
</div>
<PasswordField
data-testid="password"
name="password"
id="password"
autoComplete="current-password"
required
label="Password"
ref={passwordRef}
/>
{confirmPasswordErrorMessage && <Alert severity="error" title={confirmPasswordErrorMessage} />}
</div>
<DialogFooter showDivider>
<Button
data-testid="profile-update-email-submit-button"
color="primary"
loading={confirmPasswordMutation.isPending}
onClick={(e) => onConfirmPassword(e)}>
{t("confirm")}
</Button>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreateAccountPasswordDialog} onOpenChange={setShowCreateAccountPasswordDialog}>
<DialogContent
title={t("create_account_password")}
description={t("create_account_password_hint")}
type="creation"
Icon="triangle-alert">
<DialogFooter>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAccountDisconnectWarning} onOpenChange={setShowAccountDisconnectWarning}>
<DialogContent
title={t("disconnect_account")}
description={t("disconnect_account_hint")}
type="creation"
Icon="triangle-alert">
<DialogFooter>
<Button
color="primary"
onClick={() => {
unlinkConnectedAccountMutation.mutate();
setShowAccountDisconnectWarning(false);
}}>
{t("confirm")}
</Button>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
{showSecondaryEmailModalOpen && (
<SecondaryEmailModal
isLoading={addSecondaryEmailMutation.isPending}
errorMessage={secondaryEmailAddErrorMessage}
handleAddEmail={(values) => {
setSecondaryEmailAddErrorMessage("");
addSecondaryEmailMutation.mutate(values);
}}
onCancel={() => {
setSecondaryEmailAddErrorMessage("");
setShowSecondaryEmailModalOpen(false);
}}
clearErrorMessage={() => {
addSecondaryEmailMutation.reset();
setSecondaryEmailAddErrorMessage("");
}}
/>
)}
{!!newlyAddedSecondaryEmail && (
<SecondaryEmailConfirmModal
email={newlyAddedSecondaryEmail}
onCancel={() => setNewlyAddedSecondaryEmail(undefined)}
/>
)}
</SettingsHeader>
);
};
type SecondaryEmailApiPayload = {
id: number;
email: string;
isDeleted: boolean;
};
type ExtendedFormValues = Omit<FormValues, "secondaryEmails"> & {
secondaryEmails: SecondaryEmailApiPayload[];
};
const ProfileForm = ({
defaultValues,
onSubmit,
handleAddSecondaryEmail,
handleResendVerifyEmail,
handleAccountDisconnect,
extraField,
isPending = false,
isFallbackImg,
user,
userOrganization,
isCALIdentityProvider,
}: {
defaultValues: FormValues;
onSubmit: (values: ExtendedFormValues) => void;
handleAddSecondaryEmail: () => void;
handleResendVerifyEmail: (email: string) => void;
handleAccountDisconnect: (values: ExtendedFormValues) => void;
extraField?: React.ReactNode;
isPending: boolean;
isFallbackImg: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
userOrganization: RouterOutputs["viewer"]["me"]["get"]["organization"];
isCALIdentityProvider: boolean;
}) => {
const { t } = useLocale();
const [firstRender, setFirstRender] = useState(true);
const profileFormSchema = z.object({
username: z.string(),
avatarUrl: z.string().nullable(),
name: z
.string()
.trim()
.min(1, t("you_need_to_add_a_name"))
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", {
limit: FULL_NAME_LENGTH_MAX_LIMIT,
}),
}),
email: emailSchema.toLowerCase(),
bio: z.string(),
secondaryEmails: z.array(
z.object({
id: z.number(),
email: emailSchema.toLowerCase(),
emailVerified: z.union([z.string(), z.null()]).optional(),
emailPrimary: z.boolean().optional(),
})
),
});
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(profileFormSchema),
});
const {
fields: secondaryEmailFields,
remove: deleteSecondaryEmail,
replace: updateAllSecondaryEmailFields,
} = useFieldArray({
control: formMethods.control,
name: "secondaryEmails",
keyName: "itemId",
});
const getUpdatedFormValues = (values: FormValues) => {
const changedFields = formMethods.formState.dirtyFields?.secondaryEmails || [];
const updatedValues: FormValues = {
...values,
};
// If the primary email is changed, we will need to update
const primaryEmailIndex = updatedValues.secondaryEmails.findIndex(
(secondaryEmail) => secondaryEmail.emailPrimary
);
if (primaryEmailIndex >= 0) {
// Add the new updated value as primary email
updatedValues.email = updatedValues.secondaryEmails[primaryEmailIndex].email;
}
// We will only send the emails which have already changed
const updatedEmails: Email[] = [];
changedFields.map((field, index) => {
// If the email changed and if its only secondary email, we add it for updation, the first
// item in the list is always primary email
if (field?.email && updatedValues.secondaryEmails[index]?.id) {
updatedEmails.push(updatedValues.secondaryEmails[index]);
}
});
const deletedEmails = (user?.secondaryEmails || []).filter(
(secondaryEmail) => !updatedValues.secondaryEmails.find((val) => val.id && val.id === secondaryEmail.id)
);
const secondaryEmails = [
...updatedEmails.map((email) => ({ ...email, isDeleted: false })),
...deletedEmails.map((email) => ({ ...email, isDeleted: true })),
].map((secondaryEmail) => pick(secondaryEmail, ["id", "email", "isDeleted"]));
return {
...updatedValues,
secondaryEmails,
};
};
const handleFormSubmit = (values: FormValues) => {
onSubmit(getUpdatedFormValues(values));
};
const onDisconnect = () => {
handleAccountDisconnect(getUpdatedFormValues(formMethods.getValues()));
};
const { data: usersAttributes, isPending: usersAttributesPending } =
trpc.viewer.attributes.getByUserId.useQuery({
userId: user.id,
});
const {
formState: { isSubmitting, isDirty },
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
return (
<Form form={formMethods} handleSubmit={handleFormSubmit}>
<div className="border-subtle border-x px-4 pb-10 pt-8 sm:px-6">
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatarUrl"
render={({ field: { value, onChange } }) => {
const showRemoveAvatarButton = value !== null;
return (
<>
<UserAvatar data-testid="profile-upload-avatar" previewSrc={value} size="lg" user={user} />
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_avatar")}
handleAvatarChange={(newAvatar) => {
onChange(newAvatar);
}}
imageSrc={getUserAvatarUrl({ avatarUrl: value })}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "secondary"}
/>
{showRemoveAvatarButton && (
<Button
color="minimal"
onClick={() => {
onChange(null);
}}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>
</div>
{extraField}
<p className="text-subtle mt-1 flex gap-1 text-sm">
<InfoIcon className="mt-0.5 shrink-0" />
<span className="flex-1">{t("tip_username_plus")}</span>
</p>
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-6">
<Label>{t("email")}</Label>
<div className="-mt-2 flex flex-wrap items-start gap-2">
<div
className={
secondaryEmailFields.length > 1 ? "grid w-full grid-cols-1 gap-2 sm:grid-cols-2" : "flex-1"
}>
{secondaryEmailFields.map((field, index) => (
<CustomEmailTextField
key={field.itemId}
formMethods={formMethods}
formMethodFieldName={`secondaryEmails.${index}.email` as keyof FormValues}
errorMessage={get(formMethods.formState.errors, `secondaryEmails.${index}.email.message`)}
emailVerified={Boolean(field.emailVerified)}
emailPrimary={field.emailPrimary}
dataTestId={`profile-form-email-${index}`}
handleChangePrimary={() => {
const fields = secondaryEmailFields.map((secondaryField, cIndex) => ({
...secondaryField,
emailPrimary: cIndex === index,
}));
updateAllSecondaryEmailFields(fields);
}}
handleVerifyEmail={() => handleResendVerifyEmail(field.email)}
handleItemDelete={() => deleteSecondaryEmail(index)}
/>
))}
</div>
<Button
color="secondary"
StartIcon="plus"
className="mt-2"
onClick={() => handleAddSecondaryEmail()}
data-testid="add-secondary-email">
{t("add_email")}
</Button>
</div>
</div>
<div className="mt-6">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), {
shouldDirty: true,
});
}}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
height="120px"
/>
</div>
{usersAttributes && usersAttributes?.length > 0 && (
<div className="mt-6 flex flex-col">
<Label>{t("attributes")}</Label>
<div className="stack-y-4 flex flex-col">
{usersAttributes.map((attribute, index) => (
<>
<DisplayInfo
key={index}
label={attribute.name}
labelClassname="font-normal text-sm text-subtle"
valueClassname="text-emphasis inline-flex items-center gap-1 font-normal text-sm leading-5"
value={
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(attribute.type)
? attribute.options[0].value
: attribute.options.map((option) => option.value)
}
/>
</>
))}
</div>
</div>
)}
{/* // For Non-Cal identities, we merge the values from DB and the user logging in,
so essentially there's no point in allowing them to disconnect, since when they log in they will get logged into the same account */}
{!isCALIdentityProvider && user.email !== user.identityProviderEmail && (
<div className="mt-6">
<Label>Connected accounts</Label>
<div className="flex items-center">
<span className="text-default text-sm capitalize">{user.identityProvider.toLowerCase()}</span>
{user.identityProviderEmail && (
<span className="text-default ml-2 text-sm">{user.identityProviderEmail}</span>
)}
<div className="flex flex-1 justify-end">
<Button color="destructive" onClick={onDisconnect}>
{t("disconnect")}
</Button>
</div>
</div>
</div>
)}
</div>
<SectionBottomActions align="end">
<Button
loading={isPending}
disabled={isDisabled}
color="primary"
type="submit"
data-testid="profile-submit-button">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
export default ProfileView;