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 && (
-