1 import type { ComponentProps } from 'react';
2 import { useRef, useState } from 'react';
3 import { useSelector } from 'react-redux';
5 import type { FormikContextType, FormikErrors } from 'formik';
6 import { useFormik } from 'formik';
7 import { c } from 'ttag';
9 import type { Dropzone, FileInput } from '@proton/components';
10 import { useNotifications } from '@proton/components';
11 import { usePassCore } from '@proton/pass/components/Core/PassCoreProvider';
12 import { useActionRequest } from '@proton/pass/hooks/useActionRequest';
13 import { ImportReaderError } from '@proton/pass/lib/import/helpers/error';
14 import { extractFileExtension, fileReader } from '@proton/pass/lib/import/reader';
15 import type { ImportPayload } from '@proton/pass/lib/import/types';
16 import { ImportProvider } from '@proton/pass/lib/import/types';
17 import { importItemsIntent } from '@proton/pass/store/actions';
18 import { itemsImportRequest } from '@proton/pass/store/actions/requests';
19 import type { ImportState } from '@proton/pass/store/reducers';
20 import { selectAliasItems, selectLatestImport, selectUser } from '@proton/pass/store/selectors';
21 import type { MaybeNull } from '@proton/pass/types';
22 import { first } from '@proton/pass/utils/array/first';
23 import { fileToTransferable } from '@proton/pass/utils/file/transferable-file';
24 import { orThrow, pipe } from '@proton/pass/utils/fp/pipe';
25 import { splitExtension } from '@proton/shared/lib/helpers/file';
26 import identity from '@proton/utils/identity';
28 type DropzoneProps = ComponentProps<typeof Dropzone>;
29 type FileInputProps = ComponentProps<typeof FileInput>;
31 export type ImportFormValues = { file: MaybeNull<File>; provider: MaybeNull<ImportProvider>; passphrase?: string };
33 export type ImportFormContext = {
34 form: FormikContextType<ImportFormValues>;
39 onDrop: DropzoneProps['onDrop'];
40 onAttach: FileInputProps['onChange'];
41 setSupportedFileTypes: (fileTypes: string[]) => void;
45 export type UseImportFormBeforeSubmitValue = { ok: true; payload: ImportPayload } | { ok: false };
46 export type UseImportFormBeforeSubmit = (payload: ImportPayload) => Promise<UseImportFormBeforeSubmitValue>;
48 export type UseImportFormOptions = {
49 beforeSubmit?: UseImportFormBeforeSubmit;
50 onSubmit?: (payload: ImportPayload) => void;
53 export const SUPPORTED_IMPORT_FILE_TYPES = ['json', '1pif', '1pux', 'pgp', 'zip', 'csv', 'xml'];
55 const createFileValidator = (allow: string[]) =>
57 (files: File[]) => first(files)!,
58 orThrow('Unsupported file type', (file) => allow.includes(splitExtension(file?.name)[1]), identity)
61 const getInitialFormValues = (): ImportFormValues => ({
67 const validateImportForm = ({ provider, file, passphrase }: ImportFormValues): FormikErrors<ImportFormValues> => {
68 const errors: FormikErrors<ImportFormValues> = {};
70 if (provider === null) errors.provider = c('Warning').t`No password manager selected`;
71 if (!file) errors.file = '';
73 if (file && provider === ImportProvider.PROTONPASS) {
74 const fileExtension = extractFileExtension(file.name);
75 if (fileExtension === 'pgp' && !Boolean(passphrase)) {
76 errors.passphrase = c('Warning').t`PGP encrypted export file requires passphrase`;
83 const isNonEmptyImportPayload = (payload: ImportPayload) =>
84 payload.vaults.length > 0 && payload.vaults.some(({ items }) => items.length > 0);
86 export const useImportForm = ({
87 beforeSubmit = (payload) => Promise.resolve({ ok: true, payload }),
89 }: UseImportFormOptions): ImportFormContext => {
90 const { prepareImport } = usePassCore();
91 const { createNotification } = useNotifications();
93 const [busy, setBusy] = useState(false);
94 const [dropzoneHovered, setDropzoneHovered] = useState(false);
95 const [supportedFileTypes, setSupportedFileTypes] = useState<string[]>([]);
96 const formRef = useRef<FormikContextType<ImportFormValues>>();
98 const result = useSelector(selectLatestImport);
99 const user = useSelector(selectUser);
100 const aliases = useSelector(selectAliasItems);
102 const importItems = useActionRequest(importItemsIntent, {
103 initialRequestId: itemsImportRequest(),
106 void formRef.current?.setValues(getInitialFormValues());
108 onFailure: () => setBusy(false),
111 const form = useFormik<ImportFormValues>({
112 initialValues: getInitialFormValues(),
113 initialErrors: { file: '' },
114 validateOnChange: true,
115 validateOnMount: true,
116 validate: validateImportForm,
117 onSubmit: async (values) => {
118 if (!values.provider) return setBusy(false);
122 const importPayload = await fileReader(
123 await prepareImport({
124 file: await fileToTransferable(values.file!),
125 provider: values.provider,
126 passphrase: values.passphrase,
130 values.provider === ImportProvider.PROTONPASS
131 ? aliases.reduce((acc: string[], { aliasEmail }) => {
132 if (aliasEmail) acc.push(aliasEmail);
140 if (!isNonEmptyImportPayload(importPayload)) {
141 throw new ImportReaderError(c('Error').t`The file you are trying to import is empty`);
144 const result = await beforeSubmit(importPayload);
147 onSubmit?.(result.payload);
148 importItems.dispatch({ data: result.payload, provider: values.provider });
151 if (e instanceof Error) {
152 createNotification({ type: 'error', text: e.message });
160 formRef.current = form;
162 const onAddFiles = (files: File[]) => {
164 const file = createFileValidator(supportedFileTypes)(files);
165 void form.setValues((values) => ({ ...values, file }));
167 form.setErrors({ file: e.message });
171 const onDrop = (files: File[]) => {
172 setDropzoneHovered(false);
173 onAddFiles([...files]);
176 const onAttach: FileInputProps['onChange'] = (event) => onAddFiles((event.target.files as File[] | null) ?? []);
180 dropzone: { hovered: dropzoneHovered, onAttach, onDrop, setSupportedFileTypes },