diff --git a/packages/account/package.json b/packages/account/package.json index bc6ba5119b6..b7c7b18fd1f 100644 --- a/packages/account/package.json +++ b/packages/account/package.json @@ -70,6 +70,7 @@ "react": "^18.3.1", "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", + "react-easy-crop": "^5.5.7", "react-helmet": "^6.1.0", "react-i18next": "^12.3.1", "react-modal": "^3.15.1", diff --git a/packages/account/src/components/AvatarUploadField/index.test.tsx b/packages/account/src/components/AvatarUploadField/index.test.tsx new file mode 100644 index 00000000000..309b1ae59c7 --- /dev/null +++ b/packages/account/src/components/AvatarUploadField/index.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; + +import { uploadAccountAvatar } from '@ac/apis/avatar'; + +import AvatarUploadField from '.'; + +const mockGetAccessToken = jest.fn(); + +jest.mock('@logto/react', () => ({ + useLogto: () => ({ + getAccessToken: mockGetAccessToken, + }), +})); + +jest.mock('@ac/apis/avatar', () => ({ + uploadAccountAvatar: jest.fn(), +})); + +jest.mock('@experience/utils/image-crop', () => ({ + getCroppedImageBlob: jest.fn(async () => new Blob([new Uint8Array([1])], { type: 'image/jpeg' })), +})); + +// Render the crop modal inline and immediately report a crop area so confirming works in jsdom. +jest.mock('react-modal', () => ({ + __esModule: true, + default: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => + isOpen ?
{children}
: null, +})); + +jest.mock('react-easy-crop', () => ({ + __esModule: true, + // The cropper reports the crop area synchronously so confirming has a value to export. + default: ({ + onCropComplete, + }: { + onCropComplete: (area: unknown, areaPixels: unknown) => void; + }) => { + onCropComplete({}, { x: 0, y: 0, width: 100, height: 100 }); + return
; + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.extensions) { + return `${key}:${options.extensions}`; + } + if (options?.limit) { + return `${key}:${options.limit}`; + } + return key; + }, + i18n: { dir: () => 'ltr' }, + }), +})); + +const selectFile = (container: HTMLElement, file: File) => { + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new TypeError('file input not found'); + } + fireEvent.change(input, { target: { files: [file] } }); +}; + +const validImageFile = () => + new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { type: 'image/jpeg' }); + +const urlObjectHelpersSnapshot = { + createObjectURL: undefined as typeof URL.createObjectURL | undefined, + revokeObjectURL: undefined as typeof URL.revokeObjectURL | undefined, +}; + +describe('AvatarUploadField (account center)', () => { + beforeAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + urlObjectHelpersSnapshot.createObjectURL = globalThis.URL.createObjectURL; + // eslint-disable-next-line @silverhand/fp/no-mutation + urlObjectHelpersSnapshot.revokeObjectURL = globalThis.URL.revokeObjectURL; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetAccessToken.mockResolvedValue('access-token'); + // Jsdom does not implement object URL helpers used by the crop flow. + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock-avatar'); + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.revokeObjectURL = jest.fn(); + }); + + afterAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.createObjectURL = + urlObjectHelpersSnapshot.createObjectURL ?? (() => 'blob:restored'); + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.revokeObjectURL = urlObjectHelpersSnapshot.revokeObjectURL ?? jest.fn(); + }); + + it('opens the crop modal on selection and uploads the cropped image after confirming', async () => { + jest.mocked(uploadAccountAvatar).mockResolvedValue({ url: 'https://example.com/avatar.png' }); + const onChange = jest.fn(); + + const { container, getByTestId, getByText } = render( + + ); + + selectFile(container, validImageFile()); + + // The crop modal opens and no upload happens yet. + await waitFor(() => { + expect(getByTestId('crop-modal')).toBeTruthy(); + }); + expect(uploadAccountAvatar).not.toHaveBeenCalled(); + + fireEvent.click(getByText('action.save')); + + await waitFor(() => { + expect(uploadAccountAvatar).toHaveBeenCalledTimes(1); + }); + + const [accessToken, uploadedFile, options] = + jest.mocked(uploadAccountAvatar).mock.calls[0] ?? []; + expect(accessToken).toBe('access-token'); + expect(uploadedFile).toBeInstanceOf(File); + expect(uploadedFile?.type).toBe('image/jpeg'); + expect(options?.signal).toBeInstanceOf(AbortSignal); + expect(onChange).toHaveBeenCalledWith('https://example.com/avatar.png'); + }); + + it('shows a client-side error for unsupported file types and does not open the modal', async () => { + const onChange = jest.fn(); + + const { container, getByRole, queryByTestId } = render( + + ); + + selectFile(container, new File(['text'], 'notes.txt', { type: 'text/plain' })); + + await waitFor(() => { + expect(getByRole('alert').textContent).toBe('error_file_type:JPEG, PNG, GIF, WebP, BMP'); + }); + expect(queryByTestId('crop-modal')).toBeNull(); + expect(uploadAccountAvatar).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('surfaces a generic error and does not update the value when upload fails', async () => { + jest.mocked(uploadAccountAvatar).mockRejectedValue(new Error('network error')); + const onChange = jest.fn(); + + const { container, getByTestId, getByText, getByRole } = render( + + ); + + selectFile(container, validImageFile()); + + await waitFor(() => { + expect(getByTestId('crop-modal')).toBeTruthy(); + }); + + fireEvent.click(getByText('action.save')); + + await waitFor(() => { + expect(getByRole('alert').textContent).toBe('error_upload'); + }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/account/src/components/AvatarUploadField/index.tsx b/packages/account/src/components/AvatarUploadField/index.tsx index bc12fc32982..1f5edfe471c 100644 --- a/packages/account/src/components/AvatarUploadField/index.tsx +++ b/packages/account/src/components/AvatarUploadField/index.tsx @@ -1,17 +1,11 @@ import UserAvatar from '@experience/assets/icons/default-user-avatar.svg?react'; +import AvatarCropModal from '@experience/components/AvatarCropModal'; +import useAvatarCropUpload from '@experience/hooks/use-avatar-crop-upload'; import RotatingRingIcon from '@experience/shared/components/Button/RotatingRingIcon'; -import { - avatarFileAccept, - avatarFileExtensions, - formatFileSizeLimit, - getAvatarUploadErrorMessage, - validateAvatarFile, -} from '@experience/utils/avatar-upload'; +import { avatarFileAccept } from '@experience/utils/avatar-upload'; import { useLogto } from '@logto/react'; -import { maxUploadFileSize, type RequestErrorBody } from '@logto/schemas'; import classNames from 'classnames'; -import { HTTPError } from 'ky'; -import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useCallback, useId, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { uploadAccountAvatar } from '@ac/apis/avatar'; @@ -21,9 +15,6 @@ import profileStyles from '../../pages/Profile/index.module.scss'; import styles from './index.module.scss'; -const isAbortError = (error: unknown) => - (error instanceof DOMException || error instanceof Error) && error.name === 'AbortError'; - type Props = { readonly className?: string; readonly label: string; @@ -37,104 +28,37 @@ const AvatarUploadField = ({ className, label, value = '', onChange }: Props) => const { getAccessToken } = useLogto(); const inputId = useId(); const inputRef = useRef(null); - const abortControllerRef = useRef(); - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(); - const [fileInputKey, setFileInputKey] = useState(0); - - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - }; - }, []); - - const openFilePicker = useCallback(() => { - if (isUploading) { - return; - } - - inputRef.current?.click(); - }, [isUploading]); - const resetFileInput = useCallback(() => { - setFileInputKey((key) => key + 1); - }, []); + const upload = useCallback( + async (file: File, options: { signal: AbortSignal }) => { + const accessToken = await getAccessToken(); - const handleUploadError = useCallback( - async (error: unknown) => { - if (error instanceof HTTPError) { - try { - const errorBody = await error.response.json(); - setUploadError(getAvatarUploadErrorMessage(errorBody, tAvatar)); - return; - } catch { - // Fall through to generic error message. - } + if (!accessToken) { + throw new Error('Session expired'); } - setUploadError(tAvatar('error_upload')); + return uploadAccountAvatar(accessToken, file, options); }, - [tAvatar] + [getAccessToken] ); - const handleFileChange = useCallback( - async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - - if (!file) { - return; - } - - const validationError = validateAvatarFile(file); - - if (validationError === 'file_size_exceeded') { - setUploadError( - tAvatar('error_file_size', { limit: formatFileSizeLimit(maxUploadFileSize) }) - ); - resetFileInput(); - return; - } + const { + cropImageSource, + isUploading, + uploadError, + fileInputKey, + handleFileChange, + handleCropCancel, + handleCropConfirm, + } = useAvatarCropUpload({ upload, onChange }); - if (validationError === 'file_type') { - setUploadError(tAvatar('error_file_type', { extensions: avatarFileExtensions })); - resetFileInput(); - return; - } - - abortControllerRef.current?.abort(); - const abortController = new AbortController(); - // eslint-disable-next-line @silverhand/fp/no-mutation - abortControllerRef.current = abortController; - - setUploadError(undefined); - setIsUploading(true); - - try { - const accessToken = await getAccessToken(); - - if (!accessToken) { - throw new Error('Session expired'); - } - - const { url } = await uploadAccountAvatar(accessToken, file, { - signal: abortController.signal, - }); - await onChange(url); - } catch (error: unknown) { - if (isAbortError(error) || abortController.signal.aborted) { - return; - } + const openFilePicker = useCallback(() => { + if (isUploading) { + return; + } - await handleUploadError(error); - } finally { - if (!abortController.signal.aborted) { - setIsUploading(false); - setFileInputKey((key) => key + 1); - } - } - }, - [getAccessToken, handleUploadError, onChange, resetFileInput, tAvatar] - ); + inputRef.current?.click(); + }, [isUploading]); const actionLabel = isUploading ? tAvatar('uploading') @@ -175,7 +99,7 @@ const AvatarUploadField = ({ className, label, value = '', onChange }: Props) => ) : ( )} - {uploadError && ( + {uploadError && !cropImageSource && ( {uploadError} @@ -191,6 +115,13 @@ const AvatarUploadField = ({ className, label, value = '', onChange }: Props) => accept={avatarFileAccept} onChange={handleFileChange} /> +
); }; diff --git a/packages/account/src/pages/Profile/index.test.tsx b/packages/account/src/pages/Profile/index.test.tsx index b69f5efc834..8552200ac21 100644 --- a/packages/account/src/pages/Profile/index.test.tsx +++ b/packages/account/src/pages/Profile/index.test.tsx @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import type { SignInExperienceResponse } from '@experience/shared/types'; +import { getCroppedImageBlob } from '@experience/utils/image-crop'; import { AccountCenterControlValue, CustomProfileFieldType, @@ -41,6 +42,23 @@ jest.mock('../../apis/avatar', () => ({ uploadAccountAvatar: jest.fn(), })); +// The avatar field opens a crop modal on selection; stub the cropper/canvas so the flow runs in jsdom. +jest.mock('@experience/utils/image-crop', () => ({ + getCroppedImageBlob: jest.fn(async () => new Blob([new Uint8Array([1])], { type: 'image/jpeg' })), +})); + +jest.mock('react-easy-crop', () => ({ + __esModule: true, + default: ({ + onCropComplete, + }: { + onCropComplete: (area: unknown, areaPixels: unknown) => void; + }) => { + onCropComplete({}, { x: 0, y: 0, width: 100, height: 100 }); + return null; + }, +})); + type ProfileRenderOptions = { readonly accountCenterSettings?: Omit, 'fields'> & { readonly fields?: Partial; @@ -212,7 +230,19 @@ const renderProfile = ({ ); }; +const urlObjectHelpersSnapshot = { + createObjectURL: undefined as typeof URL.createObjectURL | undefined, + revokeObjectURL: undefined as typeof URL.revokeObjectURL | undefined, +}; + describe('', () => { + beforeAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + urlObjectHelpersSnapshot.createObjectURL = globalThis.URL.createObjectURL; + // eslint-disable-next-line @silverhand/fp/no-mutation + urlObjectHelpersSnapshot.revokeObjectURL = globalThis.URL.revokeObjectURL; + }); + beforeEach(() => { jest.resetAllMocks(); mockGetAccessToken.mockResolvedValue('access-token'); @@ -220,6 +250,23 @@ describe('', () => { jest.mocked(updateCustomData).mockResolvedValue(undefined); jest.mocked(updateName).mockResolvedValue(undefined); jest.mocked(updateProfile).mockResolvedValue(undefined); + // `resetAllMocks` clears the factory implementation, so restore the cropper stub each run. + jest + .mocked(getCroppedImageBlob) + .mockResolvedValue(new Blob([new Uint8Array([1])], { type: 'image/jpeg' })); + // Jsdom does not implement object URL helpers used by the avatar crop flow. + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock-avatar'); + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.revokeObjectURL = jest.fn(); + }); + + afterAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.createObjectURL = + urlObjectHelpersSnapshot.createObjectURL ?? (() => 'blob:restored'); + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.revokeObjectURL = urlObjectHelpersSnapshot.revokeObjectURL ?? jest.fn(); }); it('renders editable profile fields with expected sections and edit entries', () => { @@ -329,26 +376,70 @@ describe('', () => { expect(queryByText('profile.avatar_upload.upload')).toBeNull(); }); - it('uploads avatar and persists URL via updateAvatar', async () => { + it('crops then uploads avatar and persists URL via updateAvatar', async () => { const refreshUserInfo = jest.fn().mockResolvedValue(undefined); const setToast = jest.fn(); const mockUrl = 'https://example.com/new-avatar.png'; jest.mocked(uploadAccountAvatar).mockResolvedValueOnce({ url: mockUrl }); - const { container } = renderProfile({ refreshUserInfo, setToast }); + const { container, getByText } = renderProfile({ refreshUserInfo, setToast }); const fileInput = container.querySelector('input[type="file"]')!; - const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + const file = new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { + type: 'image/jpeg', + }); + // Selecting a file opens the crop modal; the upload only happens after confirming. fireEvent.change(fileInput, { target: { files: [file] } }); + expect(uploadAccountAvatar).not.toHaveBeenCalled(); + + fireEvent.click(getByText('action.save')); await waitFor(() => { - expect(uploadAccountAvatar).toHaveBeenCalledWith('access-token', file, expect.any(Object)); + expect(uploadAccountAvatar).toHaveBeenCalledWith( + 'access-token', + expect.any(File), + expect.any(Object) + ); }); await waitFor(() => { expect(updateAvatar).toHaveBeenCalledWith('access-token', { avatar: mockUrl }); }); }); + it('keeps the crop modal open when persist fails after upload', async () => { + const refreshUserInfo = jest.fn().mockResolvedValue(undefined); + const setToast = jest.fn(); + const mockUrl = 'https://example.com/new-avatar.png'; + jest.mocked(uploadAccountAvatar).mockResolvedValueOnce({ url: mockUrl }); + jest.mocked(updateAvatar).mockRejectedValue(new Error('network error')); + + const { container, getByText } = renderProfile({ refreshUserInfo, setToast }); + const fileInput = container.querySelector('input[type="file"]')!; + const file = new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { + type: 'image/jpeg', + }); + + fireEvent.change(fileInput, { target: { files: [file] } }); + fireEvent.click(getByText('action.save')); + + await waitFor(() => { + expect(updateAvatar).toHaveBeenCalledWith('access-token', { avatar: mockUrl }); + }); + await waitFor(() => { + expect(getByText('profile.avatar_upload.error_save')).toBeTruthy(); + }); + expect(getByText('action.save')).toBeTruthy(); + expect(refreshUserInfo).not.toHaveBeenCalled(); + expect(setToast).not.toHaveBeenCalled(); + + fireEvent.click(getByText('action.save')); + + await waitFor(() => { + expect(updateAvatar).toHaveBeenCalledTimes(2); + }); + expect(uploadAccountAvatar).toHaveBeenCalledTimes(1); + }); + it('renders avatar image in read-only mode with label and not_set placeholder', () => { const { queryByAltText, unmount } = renderProfile({ accountCenterSettings: { diff --git a/packages/account/src/pages/Profile/index.tsx b/packages/account/src/pages/Profile/index.tsx index 006d77203f3..b892a664e5e 100644 --- a/packages/account/src/pages/Profile/index.tsx +++ b/packages/account/src/pages/Profile/index.tsx @@ -10,7 +10,6 @@ import AvatarUploadField from '@ac/components/AvatarUploadField'; import PageFooter from '@ac/components/PageFooter'; import { layoutClassNames } from '@ac/constants/layout'; import useApi from '@ac/hooks/use-api'; -import useErrorHandler from '@ac/hooks/use-error-handler'; import homeStyles from '../Home/index.module.scss'; @@ -84,21 +83,19 @@ const Profile = () => { ]); const updateAvatarRequest = useApi(updateAvatar); - const handleError = useErrorHandler(); const handleAvatarChange = useCallback( async (avatarUrl: string) => { const [error] = await updateAvatarRequest({ avatar: avatarUrl }); if (error) { - await handleError(error); - return; + throw error instanceof Error ? error : new Error(String(error)); } await refreshUserInfo(); setToast(t('account_center.update_success.default.description')); }, - [handleError, refreshUserInfo, setToast, t, updateAvatarRequest] + [refreshUserInfo, setToast, t, updateAvatarRequest] ); const handleUpdated = useCallback(async () => { diff --git a/packages/core/src/middleware/koa-security-headers.ts b/packages/core/src/middleware/koa-security-headers.ts index 2a0c4a2d7e6..5e316db6c08 100644 --- a/packages/core/src/middleware/koa-security-headers.ts +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -75,6 +75,12 @@ const createSecurityHeaderSettings = (tenantId: string): SecurityHeaderSettings ? [] : ['http://localhost:9000', 'http://127.0.0.1:9000']; const userAssetImageSources = ["'self'", 'data:', 'https:', ...developmentUserAssetImageSources]; + /** + * Avatar upload surfaces (Experience and Account Center) preview the locally selected image + * via an object URL (`blob:`) inside the crop modal. Scoped to these two apps so Console's + * `img-src` is not widened. + */ + const avatarCropImageSources = [...userAssetImageSources, 'blob:']; const logtoOrigin = 'https://*.logto.io'; /** Google Sign-In (GSI) origin for Google One Tap. */ const gsiOrigin = 'https://accounts.google.com/gsi/'; @@ -166,7 +172,7 @@ const createSecurityHeaderSettings = (tenantId: string): SecurityHeaderSettings useDefaults: true, directives: { 'upgrade-insecure-requests': null, - imgSrc: userAssetImageSources, + imgSrc: avatarCropImageSources, scriptSrc: appendCustomSources(experienceScriptSource, customUiCsp.scriptSrc), scriptSrcAttr: ["'unsafe-inline'"], connectSrc: appendCustomSources(experienceConnectSource, customUiCsp.connectSrc), @@ -192,7 +198,7 @@ const createSecurityHeaderSettings = (tenantId: string): SecurityHeaderSettings useDefaults: true, directives: { 'upgrade-insecure-requests': null, - imgSrc: userAssetImageSources, + imgSrc: avatarCropImageSources, scriptSrc: [ "'self'", // Some of our users may use the Cloudflare Web Analytics service. We need to allow it to load its scripts. diff --git a/packages/experience/jest.config.ts b/packages/experience/jest.config.ts index 4533c0ce82e..553b7b9a703 100644 --- a/packages/experience/jest.config.ts +++ b/packages/experience/jest.config.ts @@ -27,6 +27,8 @@ const config: Config.InitialOptions = { moduleNameMapper: { // Ensure CSS modules are stubbed before applying path aliases. '\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', + // Stub plain stylesheet imports (e.g. third-party CSS like react-easy-crop). + '\\.(css|sass|scss)$': 'identity-obj-proxy', '^@/([^?]*)(\\?.*)?$': '/src/$1', '^@logto/shared/(.*)$': '/../shared/lib/$1', }, diff --git a/packages/experience/package.json b/packages/experience/package.json index ac955ba9efb..bb01ec24b2a 100644 --- a/packages/experience/package.json +++ b/packages/experience/package.json @@ -76,6 +76,7 @@ "react": "^18.3.1", "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", + "react-easy-crop": "^5.5.7", "react-helmet": "^6.1.0", "react-hook-form": "^7.56.1", "react-i18next": "^12.3.1", diff --git a/packages/experience/src/components/AvatarCropModal/index.module.scss b/packages/experience/src/components/AvatarCropModal/index.module.scss new file mode 100644 index 00000000000..67c5ae1f079 --- /dev/null +++ b/packages/experience/src/components/AvatarCropModal/index.module.scss @@ -0,0 +1,91 @@ +@use '@/shared/scss/underscore' as _; + +.modal { + position: absolute; + width: 480px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + outline: none; + border-radius: 16px; + + &:focus-visible { + outline: none; + } +} + +.container { + background: var(--color-bg-float-overlay); + border-radius: 16px; + padding: _.unit(6); + + &:focus-visible { + outline: none; + } +} + +.header { + font: var(--font-title-2); + color: var(--color-type-primary); + @include _.flex-row; + justify-content: space-between; + margin-bottom: _.unit(4); + + svg { + width: 24px; + height: 24px; + } +} + +.cropArea { + position: relative; + width: 100%; + height: 320px; + border-radius: _.unit(2); + overflow: hidden; + background: var(--color-bg-layer-2); +} + +.zoomRow { + @include _.flex-row; + gap: _.unit(3); + margin-top: _.unit(4); +} + +.zoomLabel { + font: var(--font-body-2); + color: var(--color-type-secondary); +} + +.zoomSlider { + flex: 1; + accent-color: var(--color-brand-default); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 50%; + } +} + +.errorText { + font: var(--font-body-3); + color: var(--color-danger-default); + margin-top: _.unit(3); +} + +.footer { + @include _.flex-row; + justify-content: flex-end; + margin-top: _.unit(6); + + > button:first-child { + margin-inline-end: _.unit(3); + } +} + +@media only screen and (max-width: 640px) { + .modal { + width: calc(100% - 40px); + } +} diff --git a/packages/experience/src/components/AvatarCropModal/index.tsx b/packages/experience/src/components/AvatarCropModal/index.tsx new file mode 100644 index 00000000000..70b8dad7f73 --- /dev/null +++ b/packages/experience/src/components/AvatarCropModal/index.tsx @@ -0,0 +1,187 @@ +import { useCallback, useRef, useState } from 'react'; +import Cropper from 'react-easy-crop'; +import 'react-easy-crop/react-easy-crop.css'; +import { useTranslation } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import modalStyles from '../../scss/modal.module.scss'; +import Button from '../../shared/components/Button'; +import IconButton from '../../shared/components/IconButton'; +import { getCroppedImageBlob, type CropAreaPixels } from '../../utils/image-crop'; + +import styles from './index.module.scss'; + +// Inlined to keep this shared modal resolvable from both the experience and account jest/build +// pipelines, which use different module aliases and svg-import handling. +const CloseIcon = () => ( + + + +); + +const minZoom = 1; +const maxZoom = 3; +const zoomStep = 0.05; + +type Props = { + /** Object URL (or data URL) of the image to crop. The modal opens whenever this is set. */ + readonly imageSource?: string; + readonly isUploading?: boolean; + readonly uploadError?: string; + readonly onCancel: () => void; + readonly onConfirm: (blob: Blob) => void | Promise; +}; + +const AvatarCropModal = ({ + imageSource, + isUploading = false, + uploadError, + onCancel, + onConfirm, +}: Props) => { + const { t: tAction } = useTranslation(undefined, { keyPrefix: 'action' }); + const { t: tAvatar } = useTranslation(undefined, { keyPrefix: 'profile.avatar_upload' }); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(minZoom); + const [cropError, setCropError] = useState(); + const [isCropping, setIsCropping] = useState(false); + const croppedAreaPixelsRef = useRef(); + const isBusy = isUploading || isCropping; + + const handleCropComplete = useCallback( + (_croppedArea: CropAreaPixels, croppedAreaPixels: CropAreaPixels) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + croppedAreaPixelsRef.current = croppedAreaPixels; + }, + [] + ); + + const resetState = useCallback(() => { + setCrop({ x: 0, y: 0 }); + setZoom(minZoom); + setCropError(undefined); + setIsCropping(false); + // eslint-disable-next-line @silverhand/fp/no-mutation + croppedAreaPixelsRef.current = undefined; + }, []); + + const handleCancel = useCallback(() => { + if (isBusy) { + return; + } + + resetState(); + onCancel(); + }, [isBusy, onCancel, resetState]); + + const handleConfirm = useCallback(async () => { + if (!imageSource || !croppedAreaPixelsRef.current || isBusy) { + return; + } + + setCropError(undefined); + setIsCropping(true); + + try { + const cropResult = await getCroppedImageBlob(imageSource, croppedAreaPixelsRef.current) + .then((blob) => ({ blob, ok: true as const })) + .catch(() => ({ ok: false as const })); + + if (!cropResult.ok) { + setCropError(tAvatar('error_crop')); + return; + } + + await onConfirm(cropResult.blob); + } finally { + setIsCropping(false); + } + }, [imageSource, isBusy, onConfirm, tAvatar]); + + return ( + +
+
+ {tAvatar('crop_title')} + + + +
+
+ {imageSource && ( + + )} +
+
+ {tAvatar('zoom')} + { + setZoom(Number(event.target.value)); + }} + /> +
+ {(cropError ?? uploadError) && ( +
+ {cropError ?? uploadError} +
+ )} +
+
+
+
+ ); +}; + +export default AvatarCropModal; diff --git a/packages/experience/src/components/InputFields/AvatarUploadField/index.test.tsx b/packages/experience/src/components/InputFields/AvatarUploadField/index.test.tsx index 08fe01617ac..dd422fa74d4 100644 --- a/packages/experience/src/components/InputFields/AvatarUploadField/index.test.tsx +++ b/packages/experience/src/components/InputFields/AvatarUploadField/index.test.tsx @@ -8,6 +8,41 @@ jest.mock('@/apis/experience/avatar', () => ({ uploadAvatar: jest.fn(), })); +jest.mock('@/utils/image-crop', () => ({ + getCroppedImageBlob: jest.fn(async () => new Blob([new Uint8Array([1])], { type: 'image/jpeg' })), +})); + +// Render the crop modal inline and immediately report a crop area so confirming works in jsdom. +jest.mock('react-modal', () => ({ + __esModule: true, + default: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => + isOpen ?
{children}
: null, +})); + +jest.mock('react-easy-crop', () => ({ + __esModule: true, + // The cropper reports the crop area synchronously so confirming has a value to export. + default: ({ + onCropComplete, + }: { + onCropComplete: (area: unknown, areaPixels: unknown) => void; + }) => { + onCropComplete({}, { x: 0, y: 0, width: 100, height: 100 }); + return
; + }, +})); + +const selectFile = (container: HTMLElement, file: File) => { + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new TypeError('file input not found'); + } + fireEvent.change(input, { target: { files: [file] } }); +}; + +const validImageFile = () => + new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { type: 'image/jpeg' }); + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => { @@ -23,36 +58,61 @@ jest.mock('react-i18next', () => ({ }), })); +const urlObjectHelpersSnapshot = { + createObjectURL: undefined as typeof URL.createObjectURL | undefined, + revokeObjectURL: undefined as typeof URL.revokeObjectURL | undefined, +}; + describe('AvatarUploadField', () => { + beforeAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + urlObjectHelpersSnapshot.createObjectURL = globalThis.URL.createObjectURL; + // eslint-disable-next-line @silverhand/fp/no-mutation + urlObjectHelpersSnapshot.revokeObjectURL = globalThis.URL.revokeObjectURL; + }); + beforeEach(() => { jest.clearAllMocks(); + // The jsdom environment does not implement object URL helpers used by the crop flow. + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock-avatar'); + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.revokeObjectURL = jest.fn(); }); - it('uploads a valid image and updates the value', async () => { + afterAll(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.createObjectURL = + urlObjectHelpersSnapshot.createObjectURL ?? (() => 'blob:restored'); + // eslint-disable-next-line @silverhand/fp/no-mutation + globalThis.URL.revokeObjectURL = urlObjectHelpersSnapshot.revokeObjectURL ?? jest.fn(); + }); + + it('opens the crop modal on selection and uploads the cropped image after confirming', async () => { jest.mocked(uploadAvatar).mockResolvedValue({ url: 'https://example.com/avatar.png' }); const onChange = jest.fn(); - const { container } = render( + const { container, getByTestId, getByText } = render( ); - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new TypeError('file input not found'); - } + selectFile(container, validImageFile()); - const file = new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { - type: 'image/jpeg', + // The crop modal opens and no upload happens yet. + await waitFor(() => { + expect(getByTestId('crop-modal')).toBeTruthy(); }); + expect(uploadAvatar).not.toHaveBeenCalled(); - fireEvent.change(input, { target: { files: [file] } }); + fireEvent.click(getByText('action.save')); await waitFor(() => { expect(uploadAvatar).toHaveBeenCalledTimes(1); }); const [uploadedFile, options] = jest.mocked(uploadAvatar).mock.calls[0] ?? []; - expect(uploadedFile).toBe(file); + expect(uploadedFile).toBeInstanceOf(File); + expect(uploadedFile?.type).toBe('image/jpeg'); expect(options?.signal).toBeInstanceOf(AbortSignal); expect(onChange).toHaveBeenCalledWith('https://example.com/avatar.png'); }); @@ -60,21 +120,16 @@ describe('AvatarUploadField', () => { it('shows a client-side error for unsupported file types', async () => { const onChange = jest.fn(); - const { container, getByRole } = render( + const { container, getByRole, queryByTestId } = render( ); - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new TypeError('file input not found'); - } - - const file = new File(['text'], 'notes.txt', { type: 'text/plain' }); - fireEvent.change(input, { target: { files: [file] } }); + selectFile(container, new File(['text'], 'notes.txt', { type: 'text/plain' })); await waitFor(() => { expect(getByRole('alert').textContent).toBe('error_file_type:JPEG, PNG, GIF, WebP, BMP'); }); + expect(queryByTestId('crop-modal')).toBeNull(); expect(uploadAvatar).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled(); }); @@ -91,7 +146,7 @@ describe('AvatarUploadField', () => { const onUploadingChange = jest.fn(); const onChange = jest.fn(); - const { container } = render( + const { container, getByText } = render( { /> ); - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new TypeError('file input not found'); - } - - const file = new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { - type: 'image/jpeg', - }); + selectFile(container, validImageFile()); - fireEvent.change(input, { target: { files: [file] } }); + fireEvent.click(getByText('action.save')); await waitFor(() => { expect(onUploadingChange).toHaveBeenCalledWith(true); diff --git a/packages/experience/src/components/InputFields/AvatarUploadField/index.tsx b/packages/experience/src/components/InputFields/AvatarUploadField/index.tsx index 059b7d6fe8e..21dbb5e1c53 100644 --- a/packages/experience/src/components/InputFields/AvatarUploadField/index.tsx +++ b/packages/experience/src/components/InputFields/AvatarUploadField/index.tsx @@ -1,25 +1,17 @@ -import { maxUploadFileSize, type RequestErrorBody } from '@logto/schemas'; +import { maxUploadFileSize } from '@logto/schemas'; import classNames from 'classnames'; -import { HTTPError } from 'ky'; -import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { uploadAvatar } from '@/apis/experience/avatar'; import UserAvatar from '@/assets/icons/default-user-avatar.svg?react'; +import AvatarCropModal from '@/components/AvatarCropModal'; +import useAvatarCropUpload from '@/hooks/use-avatar-crop-upload'; import RotatingRingIcon from '@/shared/components/Button/RotatingRingIcon'; -import { - avatarFileAccept, - avatarFileExtensions, - formatFileSizeLimit, - getAvatarUploadErrorMessage, - validateAvatarFile, -} from '@/utils/avatar-upload'; +import { avatarFileAccept, formatFileSizeLimit } from '@/utils/avatar-upload'; import styles from './index.module.scss'; -const isAbortError = (error: unknown) => - (error instanceof DOMException || error instanceof Error) && error.name === 'AbortError'; - type Props = { readonly className?: string; readonly name: string; @@ -29,7 +21,7 @@ type Props = { readonly value?: string; readonly errorMessage?: string; readonly onBlur?: () => void; - readonly onChange: (value: string) => void; + readonly onChange: (value: string) => void | Promise; readonly onUploadingChange?: (isUploading: boolean) => void; }; @@ -49,21 +41,22 @@ const AvatarUploadField = ({ const { t: tAvatar } = useTranslation(undefined, { keyPrefix: 'profile.avatar_upload' }); const inputId = useId(); const inputRef = useRef(null); - const abortControllerRef = useRef(); const onUploadingChangeRef = useRef(onUploadingChange); - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(); - const [fileInputKey, setFileInputKey] = useState(0); + + const { + cropImageSource, + isUploading, + uploadError, + fileInputKey, + clearUploadError, + handleFileChange, + handleCropCancel, + handleCropConfirm, + } = useAvatarCropUpload({ upload: uploadAvatar, onChange, onBlur }); const labelWithOptionalSuffix = label && (isRequired ? label : t('input.label_with_optional', { label })); - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - }; - }, []); - useEffect(() => { // eslint-disable-next-line @silverhand/fp/no-mutation -- keep latest parent callback in a ref onUploadingChangeRef.current = onUploadingChange; @@ -87,86 +80,13 @@ const AvatarUploadField = ({ inputRef.current?.click(); }, [isUploading]); - const resetFileInput = useCallback(() => { - setFileInputKey((key) => key + 1); - }, []); - - const handleUploadError = useCallback( - async (error: unknown) => { - if (error instanceof HTTPError) { - try { - const errorBody = await error.response.json(); - setUploadError(getAvatarUploadErrorMessage(errorBody, tAvatar)); - return; - } catch { - // Fall through to generic error message. - } - } - - setUploadError(tAvatar('error_upload')); - }, - [tAvatar] - ); - - const handleFileChange = useCallback( - async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - - if (!file) { - return; - } - - const validationError = validateAvatarFile(file); - - if (validationError === 'file_size_exceeded') { - setUploadError( - tAvatar('error_file_size', { limit: formatFileSizeLimit(maxUploadFileSize) }) - ); - resetFileInput(); - return; - } - - if (validationError === 'file_type') { - setUploadError(tAvatar('error_file_type', { extensions: avatarFileExtensions })); - resetFileInput(); - return; - } - - abortControllerRef.current?.abort(); - const abortController = new AbortController(); - // eslint-disable-next-line @silverhand/fp/no-mutation - abortControllerRef.current = abortController; - - setUploadError(undefined); - setIsUploading(true); - - try { - const { url } = await uploadAvatar(file, { signal: abortController.signal }); - onChange(url); - onBlur?.(); - } catch (error: unknown) { - if (isAbortError(error) || abortController.signal.aborted) { - return; - } - - await handleUploadError(error); - } finally { - if (!abortController.signal.aborted) { - setIsUploading(false); - setFileInputKey((key) => key + 1); - } - } - }, - [handleUploadError, onBlur, onChange, resetFileInput, tAvatar] - ); - - const handleRemove = useCallback(() => { - setUploadError(undefined); - onChange(''); + const handleRemove = useCallback(async () => { + clearUploadError(); + await onChange(''); onBlur?.(); - }, [onBlur, onChange]); + }, [clearUploadError, onBlur, onChange]); - const displayError = uploadError ?? errorMessage; + const displayError = cropImageSource ? errorMessage : (uploadError ?? errorMessage); const showRemove = Boolean(value) && !isRequired && !isUploading; const showHint = !displayError && !isUploading; @@ -205,7 +125,13 @@ const AvatarUploadField = ({ {isUploading ? tAvatar('uploading') : tAvatar('upload')} {showRemove && ( - )} @@ -234,6 +160,13 @@ const AvatarUploadField = ({ onChange={handleFileChange} />
+ ); }; diff --git a/packages/experience/src/hooks/use-avatar-crop-upload.ts b/packages/experience/src/hooks/use-avatar-crop-upload.ts new file mode 100644 index 00000000000..2092a4a769c --- /dev/null +++ b/packages/experience/src/hooks/use-avatar-crop-upload.ts @@ -0,0 +1,205 @@ +import { maxUploadFileSize, type RequestErrorBody } from '@logto/schemas'; +import { HTTPError } from 'ky'; +import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + avatarFileExtensions, + buildCroppedAvatarFile, + formatFileSizeLimit, + getAvatarPersistErrorMessage, + getAvatarUploadErrorMessage, + validateAvatarFile, +} from '../utils/avatar-upload'; + +const isAbortError = (error: unknown) => + (error instanceof DOMException || error instanceof Error) && error.name === 'AbortError'; + +type UploadAvatar = (file: File, options: { signal: AbortSignal }) => Promise<{ url: string }>; + +type Options = { + /** Uploads the cropped avatar file and resolves with the stored asset URL. */ + readonly upload: UploadAvatar; + /** Persists the uploaded avatar URL into the consuming form/value. */ + readonly onChange: (value: string) => void | Promise; + /** Optional blur callback, used by the Experience form to trigger validation. */ + readonly onBlur?: () => void; +}; + +/** + * Shared avatar select → crop → upload orchestration for the Experience and Account Center + * avatar fields. + * + * Owns the object-URL lifecycle, abort controller, client-side validation, crop-to-upload flow, + * and HTTP error mapping so both surfaces stay in sync. The crop modal UI is provided separately + * by `AvatarCropModal`; this hook only manages the state that drives it. + */ +const useAvatarCropUpload = ({ upload, onChange, onBlur }: Options) => { + const { t: tAvatar } = useTranslation(undefined, { keyPrefix: 'profile.avatar_upload' }); + const abortControllerRef = useRef(); + const cropImageSourceRef = useRef(); + const pendingFileNameRef = useRef(); + const uploadedUrlRef = useRef(); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(); + const [fileInputKey, setFileInputKey] = useState(0); + const [cropImageSource, setCropImageSource] = useState(); + + const resetFileInput = useCallback(() => { + setFileInputKey((key) => key + 1); + }, []); + + const revokeCropImageSource = useCallback(() => { + if (cropImageSourceRef.current) { + if (typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(cropImageSourceRef.current); + } + // eslint-disable-next-line @silverhand/fp/no-mutation + cropImageSourceRef.current = undefined; + } + }, []); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + revokeCropImageSource(); + }; + }, [revokeCropImageSource]); + + const handleUploadError = useCallback( + async (error: unknown) => { + if (error instanceof HTTPError) { + try { + const errorBody = await error.response.json(); + setUploadError(getAvatarUploadErrorMessage(errorBody, tAvatar)); + return; + } catch { + // Fall through to generic error message. + } + } + + setUploadError(tAvatar('error_upload')); + }, + [tAvatar] + ); + + const handlePersistError = useCallback( + async (error: unknown) => { + if (error instanceof HTTPError) { + try { + const errorBody = await error.response.json(); + setUploadError(getAvatarPersistErrorMessage(errorBody, tAvatar)); + return; + } catch { + // Fall through to generic error message. + } + } + + setUploadError(tAvatar('error_save')); + }, + [tAvatar] + ); + + const handleFileChange = useCallback( + (event: ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + const validationError = validateAvatarFile(file); + + if (validationError === 'file_size_exceeded') { + setUploadError( + tAvatar('error_file_size', { limit: formatFileSizeLimit(maxUploadFileSize) }) + ); + resetFileInput(); + return; + } + + if (validationError === 'file_type') { + setUploadError(tAvatar('error_file_type', { extensions: String(avatarFileExtensions) })); + resetFileInput(); + return; + } + + setUploadError(undefined); + // eslint-disable-next-line @silverhand/fp/no-mutation + uploadedUrlRef.current = undefined; + revokeCropImageSource(); + const objectUrl = URL.createObjectURL(file); + // eslint-disable-next-line @silverhand/fp/no-mutation + cropImageSourceRef.current = objectUrl; + // eslint-disable-next-line @silverhand/fp/no-mutation + pendingFileNameRef.current = file.name; + setCropImageSource(objectUrl); + }, + [resetFileInput, revokeCropImageSource, tAvatar] + ); + + const handleCropCancel = useCallback(() => { + // eslint-disable-next-line @silverhand/fp/no-mutation + uploadedUrlRef.current = undefined; + revokeCropImageSource(); + setCropImageSource(undefined); + resetFileInput(); + }, [resetFileInput, revokeCropImageSource]); + + const clearUploadError = useCallback(() => { + setUploadError(undefined); + }, []); + + const handleCropConfirm = useCallback( + async (blob: Blob) => { + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + // eslint-disable-next-line @silverhand/fp/no-mutation + abortControllerRef.current = abortController; + + setUploadError(undefined); + setIsUploading(true); + + try { + if (!uploadedUrlRef.current) { + const file = buildCroppedAvatarFile(blob, pendingFileNameRef.current); + const { url } = await upload(file, { signal: abortController.signal }); + // eslint-disable-next-line @silverhand/fp/no-mutation + uploadedUrlRef.current = url; + } + + await onChange(uploadedUrlRef.current); + // eslint-disable-next-line @silverhand/fp/no-mutation + uploadedUrlRef.current = undefined; + onBlur?.(); + revokeCropImageSource(); + setCropImageSource(undefined); + } catch (error: unknown) { + if (isAbortError(error) || abortController.signal.aborted) { + return; + } + + await (uploadedUrlRef.current ? handlePersistError(error) : handleUploadError(error)); + } finally { + if (!abortController.signal.aborted) { + setIsUploading(false); + setFileInputKey((key) => key + 1); + } + } + }, + [handlePersistError, handleUploadError, onBlur, onChange, revokeCropImageSource, upload] + ); + + return { + cropImageSource, + isUploading, + uploadError, + fileInputKey, + clearUploadError, + handleFileChange, + handleCropCancel, + handleCropConfirm, + }; +}; + +export default useAvatarCropUpload; diff --git a/packages/experience/src/utils/avatar-upload.test.ts b/packages/experience/src/utils/avatar-upload.test.ts index f87a81c1270..7be89e8dede 100644 --- a/packages/experience/src/utils/avatar-upload.test.ts +++ b/packages/experience/src/utils/avatar-upload.test.ts @@ -1,4 +1,27 @@ -import { getAvatarUploadErrorMessage, validateAvatarFile } from './avatar-upload'; +import { + buildCroppedAvatarFile, + getAvatarPersistErrorMessage, + getAvatarUploadErrorMessage, + validateAvatarFile, +} from './avatar-upload'; + +describe('buildCroppedAvatarFile', () => { + it('derives a jpeg filename from the original selection', () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }); + const file = buildCroppedAvatarFile(blob, 'My Photo.png'); + + expect(file).toBeInstanceOf(File); + expect(file.name).toBe('My Photo.jpg'); + expect(file.type).toBe('image/jpeg'); + }); + + it('falls back to a default name when no original name is provided', () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }); + const file = buildCroppedAvatarFile(blob); + + expect(file.name).toBe('avatar.jpg'); + }); +}); describe('validateAvatarFile', () => { it('returns undefined for supported image types within size limit', () => { @@ -29,3 +52,17 @@ describe('getAvatarUploadErrorMessage', () => { ).toBe('error_upload'); }); }); + +describe('getAvatarPersistErrorMessage', () => { + it('maps storage.not_configured to a user-facing message', () => { + expect( + getAvatarPersistErrorMessage({ code: 'storage.not_configured' }, translateAvatarUploadError) + ).toBe('error_storage_not_configured'); + }); + + it('falls back to error_save for unknown codes', () => { + expect( + getAvatarPersistErrorMessage({ code: 'entity.not_found' }, translateAvatarUploadError) + ).toBe('error_save'); + }); +}); diff --git a/packages/experience/src/utils/avatar-upload.ts b/packages/experience/src/utils/avatar-upload.ts index b636d9e4802..64e51af675c 100644 --- a/packages/experience/src/utils/avatar-upload.ts +++ b/packages/experience/src/utils/avatar-upload.ts @@ -43,6 +43,31 @@ export const getAvatarUploadErrorMessage = ( } }; +/** + * Wrap a cropped JPEG blob into a `File` for upload, deriving the filename from the original + * selection so the backend can build a stable, human-readable object key. + */ +export const buildCroppedAvatarFile = (blob: Blob, originalFileName?: string): File => { + const baseName = originalFileName?.replace(/\.[^./\\]+$/, '').trim(); + const fileName = baseName ? `${baseName}.jpg` : 'avatar.jpg'; + + return new File([blob], fileName, { type: blob.type || 'image/jpeg' }); +}; + +export const getAvatarPersistErrorMessage = ( + { code }: Pick, + translate: AvatarUploadTranslate +) => { + switch (code) { + case 'storage.not_configured': { + return translate('error_storage_not_configured'); + } + default: { + return translate('error_save'); + } + } +}; + export const formatFileSizeLimit = (bytes: number) => { const megabytes = bytes / (1024 * 1024); diff --git a/packages/experience/src/utils/image-crop.ts b/packages/experience/src/utils/image-crop.ts new file mode 100644 index 00000000000..4c3729f6652 --- /dev/null +++ b/packages/experience/src/utils/image-crop.ts @@ -0,0 +1,81 @@ +export type CropAreaPixels = { + x: number; + y: number; + width: number; + height: number; +}; + +const loadImage = async (source: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => { + resolve(image); + }); + image.addEventListener('error', () => { + reject(new Error('Failed to load image for cropping.')); + }); + // Allow drawing cross-origin images (e.g. existing avatar URLs) onto the canvas. + /* eslint-disable @silverhand/fp/no-mutation */ + image.crossOrigin = 'anonymous'; + image.src = source; + /* eslint-enable @silverhand/fp/no-mutation */ + }); + +/** + * Crop the given image to the provided pixel area and export it as a JPEG blob. + * + * The output is always a square JPEG (the crop aspect ratio is enforced to 1:1 by the + * cropper UI) encoded at the quality recommended by the avatar upload design. + */ +export const getCroppedImageBlob = async ( + imageSource: string, + cropAreaPixels: CropAreaPixels, + { mimeType = 'image/jpeg', quality = 0.92 }: { mimeType?: string; quality?: number } = {} +): Promise => { + const image = await loadImage(imageSource); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Failed to acquire canvas context for cropping.'); + } + + const size = Math.round(Math.max(cropAreaPixels.width, cropAreaPixels.height)); + + // eslint-disable-next-line @silverhand/fp/no-mutation + canvas.width = size; + // eslint-disable-next-line @silverhand/fp/no-mutation + canvas.height = size; + + // JPEG has no alpha channel; fill with white so transparent PNG pixels are not flattened to black. + /* eslint-disable @silverhand/fp/no-mutation */ + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, size, size); + /* eslint-enable @silverhand/fp/no-mutation */ + + context.drawImage( + image, + Math.round(cropAreaPixels.x), + Math.round(cropAreaPixels.y), + size, + size, + 0, + 0, + size, + size + ); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to export cropped image.')); + } + }, + mimeType, + quality + ); + }); +}; diff --git a/packages/phrases-experience/src/locales/ar/profile.ts b/packages/phrases-experience/src/locales/ar/profile.ts index a7400ea5245..88a1f23065a 100644 --- a/packages/phrases-experience/src/locales/ar/profile.ts +++ b/packages/phrases-experience/src/locales/ar/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'يجب ألا يتجاوز حجم الملف {{limit}}.', error_storage_not_configured: 'تعذر رفع الصورة. يرجى المحاولة مرة أخرى لاحقًا.', error_upload: 'فشل رفع الصورة. يرجى المحاولة مرة أخرى.', + error_save: 'تعذر حفظ صورتك. يُرجى المحاولة مرة أخرى.', + crop_title: 'اقتصاص الصورة', + zoom: 'تكبير', + error_crop: 'فشل اقتصاص الصورة. يرجى المحاولة مرة أخرى.', }, }; diff --git a/packages/phrases-experience/src/locales/cs/profile.ts b/packages/phrases-experience/src/locales/cs/profile.ts index ddb9c09133c..26f217f245a 100644 --- a/packages/phrases-experience/src/locales/cs/profile.ts +++ b/packages/phrases-experience/src/locales/cs/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'Velikost souboru nesmí překročit {{limit}}.', error_storage_not_configured: 'Fotografii se nepodařilo nahrát. Zkuste to znovu později.', error_upload: 'Nepodařilo se nahrát fotografii. Zkuste to znovu.', + error_save: 'Nepodařilo se uložit vaši fotku. Zkuste to prosím znovu.', + crop_title: 'Oříznout fotku', + zoom: 'Přiblížení', + error_crop: 'Oříznutí obrázku se nezdařilo. Zkuste to znovu.', }, }; diff --git a/packages/phrases-experience/src/locales/de/profile.ts b/packages/phrases-experience/src/locales/de/profile.ts index c5f123426aa..6a217e23f90 100644 --- a/packages/phrases-experience/src/locales/de/profile.ts +++ b/packages/phrases-experience/src/locales/de/profile.ts @@ -40,6 +40,10 @@ const profile = { error_storage_not_configured: 'Foto konnte nicht hochgeladen werden. Bitte versuchen Sie es später erneut.', error_upload: 'Foto konnte nicht hochgeladen werden. Bitte versuchen Sie es erneut.', + error_save: 'Ihr Foto konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', + crop_title: 'Foto zuschneiden', + zoom: 'Zoom', + error_crop: 'Bild konnte nicht zugeschnitten werden. Bitte versuchen Sie es erneut.', }, }; diff --git a/packages/phrases-experience/src/locales/en/profile.ts b/packages/phrases-experience/src/locales/en/profile.ts index 3dab4fc0d66..417df580d22 100644 --- a/packages/phrases-experience/src/locales/en/profile.ts +++ b/packages/phrases-experience/src/locales/en/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'File size must not exceed {{limit}}.', error_storage_not_configured: 'Unable to upload your photo. Please try again later.', error_upload: 'Failed to upload photo. Please try again.', + error_save: 'Failed to save your photo. Please try again.', + crop_title: 'Crop your photo', + zoom: 'Zoom', + error_crop: 'Failed to crop the image. Please try again.', }, }; diff --git a/packages/phrases-experience/src/locales/es/profile.ts b/packages/phrases-experience/src/locales/es/profile.ts index 0d434c2b475..e2a6f2dd984 100644 --- a/packages/phrases-experience/src/locales/es/profile.ts +++ b/packages/phrases-experience/src/locales/es/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'El tamaño del archivo no debe superar {{limit}}.', error_storage_not_configured: 'No se pudo subir la foto. Inténtalo de nuevo más tarde.', error_upload: 'No se pudo subir la foto. Inténtalo de nuevo.', + error_save: 'No se pudo guardar tu foto. Inténtalo de nuevo.', + crop_title: 'Recortar foto', + zoom: 'Zoom', + error_crop: 'No se pudo recortar la imagen. Inténtalo de nuevo.', }, }; diff --git a/packages/phrases-experience/src/locales/fr/profile.ts b/packages/phrases-experience/src/locales/fr/profile.ts index ed7b9528b74..21f49ba7ec5 100644 --- a/packages/phrases-experience/src/locales/fr/profile.ts +++ b/packages/phrases-experience/src/locales/fr/profile.ts @@ -40,6 +40,10 @@ const profile = { error_storage_not_configured: 'Impossible de téléverser la photo. Veuillez réessayer plus tard.', error_upload: 'Échec du téléversement de la photo. Veuillez réessayer.', + error_save: 'Impossible d’enregistrer votre photo. Veuillez réessayer.', + crop_title: 'Recadrer la photo', + zoom: 'Zoom', + error_crop: "Échec du recadrage de l'image. Veuillez réessayer.", }, }; diff --git a/packages/phrases-experience/src/locales/it/profile.ts b/packages/phrases-experience/src/locales/it/profile.ts index ee9b4972eae..0fd53ece7bf 100644 --- a/packages/phrases-experience/src/locales/it/profile.ts +++ b/packages/phrases-experience/src/locales/it/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'La dimensione del file non deve superare {{limit}}.', error_storage_not_configured: 'Impossibile caricare la foto. Riprova più tardi.', error_upload: 'Impossibile caricare la foto. Riprova.', + error_save: 'Impossibile salvare la tua foto. Riprova.', + crop_title: 'Ritaglia foto', + zoom: 'Zoom', + error_crop: "Impossibile ritagliare l'immagine. Riprova.", }, }; diff --git a/packages/phrases-experience/src/locales/ja/profile.ts b/packages/phrases-experience/src/locales/ja/profile.ts index c03b8b54c2c..c5d23b4d0f2 100644 --- a/packages/phrases-experience/src/locales/ja/profile.ts +++ b/packages/phrases-experience/src/locales/ja/profile.ts @@ -40,6 +40,10 @@ const profile = { error_storage_not_configured: '写真をアップロードできません。しばらくしてからもう一度お試しください。', error_upload: '写真のアップロードに失敗しました。もう一度お試しください。', + error_save: '写真を保存できませんでした。もう一度お試しください。', + crop_title: '写真を切り抜く', + zoom: 'ズーム', + error_crop: '画像のトリミングに失敗しました。もう一度お試しください。', }, }; diff --git a/packages/phrases-experience/src/locales/ko/profile.ts b/packages/phrases-experience/src/locales/ko/profile.ts index fd3faa2facc..a15e82bfab3 100644 --- a/packages/phrases-experience/src/locales/ko/profile.ts +++ b/packages/phrases-experience/src/locales/ko/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: '파일 크기는 {{limit}}를 초과할 수 없습니다.', error_storage_not_configured: '사진을 업로드할 수 없습니다. 나중에 다시 시도해 주세요.', error_upload: '사진 업로드에 실패했습니다. 다시 시도해 주세요.', + error_save: '사진을 저장하지 못했습니다. 다시 시도해 주세요.', + crop_title: '사진 자르기', + zoom: '확대/축소', + error_crop: '이미지를 자르지 못했습니다. 다시 시도해 주세요.', }, }; diff --git a/packages/phrases-experience/src/locales/pl-pl/profile.ts b/packages/phrases-experience/src/locales/pl-pl/profile.ts index 3aae55974e8..7e366ff4568 100644 --- a/packages/phrases-experience/src/locales/pl-pl/profile.ts +++ b/packages/phrases-experience/src/locales/pl-pl/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'Rozmiar pliku nie może przekraczać {{limit}}.', error_storage_not_configured: 'Nie udało się przesłać zdjęcia. Spróbuj ponownie później.', error_upload: 'Nie udało się przesłać zdjęcia. Spróbuj ponownie.', + error_save: 'Nie udało się zapisać zdjęcia. Spróbuj ponownie.', + crop_title: 'Przytnij zdjęcie', + zoom: 'Powiększenie', + error_crop: 'Nie udało się przyciąć obrazu. Spróbuj ponownie.', }, }; diff --git a/packages/phrases-experience/src/locales/pt-br/profile.ts b/packages/phrases-experience/src/locales/pt-br/profile.ts index b3a99ab6ae2..c8bb2976073 100644 --- a/packages/phrases-experience/src/locales/pt-br/profile.ts +++ b/packages/phrases-experience/src/locales/pt-br/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'O tamanho do arquivo não pode exceder {{limit}}.', error_storage_not_configured: 'Não foi possível enviar a foto. Tente novamente mais tarde.', error_upload: 'Falha ao enviar a foto. Tente novamente.', + error_save: 'Não foi possível salvar sua foto. Tente novamente.', + crop_title: 'Recortar foto', + zoom: 'Zoom', + error_crop: 'Falha ao recortar a imagem. Tente novamente.', }, }; diff --git a/packages/phrases-experience/src/locales/pt-pt/profile.ts b/packages/phrases-experience/src/locales/pt-pt/profile.ts index fa69e76abd2..4bb5276c8b8 100644 --- a/packages/phrases-experience/src/locales/pt-pt/profile.ts +++ b/packages/phrases-experience/src/locales/pt-pt/profile.ts @@ -40,6 +40,10 @@ const profile = { error_storage_not_configured: 'Não foi possível carregar a fotografia. Tente novamente mais tarde.', error_upload: 'Falha ao carregar a fotografia. Tente novamente.', + error_save: 'Não foi possível guardar a sua fotografia. Tente novamente.', + crop_title: 'Recortar fotografia', + zoom: 'Zoom', + error_crop: 'Falha ao recortar a imagem. Tente novamente.', }, }; diff --git a/packages/phrases-experience/src/locales/ru/profile.ts b/packages/phrases-experience/src/locales/ru/profile.ts index 9a1ddc8b3fe..dfd6bc0f280 100644 --- a/packages/phrases-experience/src/locales/ru/profile.ts +++ b/packages/phrases-experience/src/locales/ru/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'Размер файла не должен превышать {{limit}}.', error_storage_not_configured: 'Не удалось загрузить фото. Повторите попытку позже.', error_upload: 'Не удалось загрузить фото. Попробуйте ещё раз.', + error_save: 'Не удалось сохранить фото. Повторите попытку.', + crop_title: 'Обрезать фото', + zoom: 'Масштаб', + error_crop: 'Не удалось обрезать изображение. Попробуйте ещё раз.', }, }; diff --git a/packages/phrases-experience/src/locales/th/profile.ts b/packages/phrases-experience/src/locales/th/profile.ts index 985b489db9a..72f1e26059c 100644 --- a/packages/phrases-experience/src/locales/th/profile.ts +++ b/packages/phrases-experience/src/locales/th/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'ขนาดไฟล์ต้องไม่เกิน {{limit}}', error_storage_not_configured: 'ไม่สามารถอัปโหลดรูปภาพได้ โปรดลองอีกครั้งในภายหลัง', error_upload: 'อัปโหลดรูปภาพไม่สำเร็จ โปรดลองอีกครั้ง', + error_save: 'ไม่สามารถบันทึกรูปภาพของคุณได้ โปรดลองอีกครั้ง', + crop_title: 'ครอบตัดรูปภาพ', + zoom: 'ซูม', + error_crop: 'ครอบตัดรูปภาพไม่สำเร็จ โปรดลองอีกครั้ง', }, }; diff --git a/packages/phrases-experience/src/locales/tr-tr/profile.ts b/packages/phrases-experience/src/locales/tr-tr/profile.ts index 221abdadd78..79e19a8677f 100644 --- a/packages/phrases-experience/src/locales/tr-tr/profile.ts +++ b/packages/phrases-experience/src/locales/tr-tr/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'Dosya boyutu {{limit}} değerini aşmamalıdır.', error_storage_not_configured: 'Fotoğraf yüklenemedi. Lütfen daha sonra tekrar deneyin.', error_upload: 'Fotoğraf yüklenemedi. Lütfen tekrar deneyin.', + error_save: 'Fotoğrafınız kaydedilemedi. Lütfen tekrar deneyin.', + crop_title: 'Fotoğrafı kırp', + zoom: 'Yakınlaştır', + error_crop: 'Görüntü kırpılamadı. Lütfen tekrar deneyin.', }, }; diff --git a/packages/phrases-experience/src/locales/uk-ua/profile.ts b/packages/phrases-experience/src/locales/uk-ua/profile.ts index fb16ed695df..d43ddb00c3d 100644 --- a/packages/phrases-experience/src/locales/uk-ua/profile.ts +++ b/packages/phrases-experience/src/locales/uk-ua/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: 'Розмір файлу не повинен перевищувати {{limit}}.', error_storage_not_configured: 'Не вдалося завантажити фото. Спробуйте пізніше.', error_upload: 'Не вдалося завантажити фото. Спробуйте ще раз.', + error_save: 'Не вдалося зберегти ваше фото. Спробуйте ще раз.', + crop_title: 'Обрізати фото', + zoom: 'Масштаб', + error_crop: 'Не вдалося обрізати зображення. Спробуйте ще раз.', }, }; diff --git a/packages/phrases-experience/src/locales/zh-cn/profile.ts b/packages/phrases-experience/src/locales/zh-cn/profile.ts index 1638cce9cff..9333fd60280 100644 --- a/packages/phrases-experience/src/locales/zh-cn/profile.ts +++ b/packages/phrases-experience/src/locales/zh-cn/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: '文件大小不能超过 {{limit}}。', error_storage_not_configured: '无法上传照片,请稍后重试。', error_upload: '照片上传失败,请重试。', + error_save: '照片保存失败,请重试。', + crop_title: '裁剪照片', + zoom: '缩放', + error_crop: '图片裁剪失败,请重试。', }, }; diff --git a/packages/phrases-experience/src/locales/zh-hk/profile.ts b/packages/phrases-experience/src/locales/zh-hk/profile.ts index b19a8d0bcf0..fd403711063 100644 --- a/packages/phrases-experience/src/locales/zh-hk/profile.ts +++ b/packages/phrases-experience/src/locales/zh-hk/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: '檔案大小不能超過 {{limit}}。', error_storage_not_configured: '無法上傳照片,請稍後重試。', error_upload: '照片上傳失敗,請重試。', + error_save: '照片儲存失敗,請重試。', + crop_title: '裁剪相片', + zoom: '縮放', + error_crop: '圖片裁剪失敗,請重試。', }, }; diff --git a/packages/phrases-experience/src/locales/zh-tw/profile.ts b/packages/phrases-experience/src/locales/zh-tw/profile.ts index d009d9792c0..5f96e79337b 100644 --- a/packages/phrases-experience/src/locales/zh-tw/profile.ts +++ b/packages/phrases-experience/src/locales/zh-tw/profile.ts @@ -39,6 +39,10 @@ const profile = { error_file_size: '檔案大小不能超過 {{limit}}。', error_storage_not_configured: '無法上傳照片,請稍後重試。', error_upload: '照片上傳失敗,請重試。', + error_save: '照片儲存失敗,請重試。', + crop_title: '裁剪相片', + zoom: '縮放', + error_crop: '圖片裁剪失敗,請重試。', }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 461c30a6e82..e4d44c1296c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-easy-crop: + specifier: ^5.5.7 + version: 5.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-helmet: specifier: ^6.1.0 version: 6.1.0(react@18.3.1) @@ -4758,6 +4761,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-easy-crop: + specifier: ^5.5.7 + version: 5.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-helmet: specifier: ^6.1.0 version: 6.1.0(react@18.3.1) @@ -13046,6 +13052,9 @@ packages: resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==} engines: {node: '>=14.16'} + normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -13113,7 +13122,7 @@ packages: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} oidc-provider@https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/5570006785b44e0f125ee4cb6bf540338721b1f3: - resolution: {gitHosted: true, tarball: https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/5570006785b44e0f125ee4cb6bf540338721b1f3} + resolution: {tarball: https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/5570006785b44e0f125ee4cb6bf540338721b1f3} version: 8.6.1 oidc-token-hash@5.0.3: @@ -13915,6 +13924,12 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' + react-easy-crop@5.5.7: + resolution: {integrity: sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + react-error-boundary@3.1.4: resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} engines: {node: '>=10', npm: '>=6'} @@ -25894,6 +25909,8 @@ snapshots: normalize-url@8.0.0: {} + normalize-wheel@1.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -26817,6 +26834,13 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 + react-easy-crop@5.5.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + normalize-wheel: 1.0.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + react-error-boundary@3.1.4(react@18.3.1): dependencies: '@babel/runtime': 7.28.4