Update selected item color in Pass menu
[ProtonMail-WebClient.git] / packages / pass / hooks / useImportForm.ts
blobbc39a19a6021506467fed31a89e0f99aced55f18
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>;
35     busy: boolean;
36     result: ImportState;
37     dropzone: {
38         hovered: boolean;
39         onDrop: DropzoneProps['onDrop'];
40         onAttach: FileInputProps['onChange'];
41         setSupportedFileTypes: (fileTypes: string[]) => void;
42     };
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[]) =>
56     pipe(
57         (files: File[]) => first(files)!,
58         orThrow('Unsupported file type', (file) => allow.includes(splitExtension(file?.name)[1]), identity)
59     );
61 const getInitialFormValues = (): ImportFormValues => ({
62     file: null,
63     provider: null,
64     passphrase: '',
65 });
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`;
77         }
78     }
80     return errors;
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 }),
88     onSubmit,
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(),
104         onSuccess: () => {
105             setBusy(false);
106             void formRef.current?.setValues(getInitialFormValues());
107         },
108         onFailure: () => setBusy(false),
109     });
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);
119             setBusy(true);
121             try {
122                 const importPayload = await fileReader(
123                     await prepareImport({
124                         file: await fileToTransferable(values.file!),
125                         provider: values.provider,
126                         passphrase: values.passphrase,
127                         userId: user?.ID,
128                         options: {
129                             currentAliases:
130                                 values.provider === ImportProvider.PROTONPASS
131                                     ? aliases.reduce((acc: string[], { aliasEmail }) => {
132                                           if (aliasEmail) acc.push(aliasEmail);
133                                           return acc;
134                                       }, [])
135                                     : [],
136                         },
137                     })
138                 );
140                 if (!isNonEmptyImportPayload(importPayload)) {
141                     throw new ImportReaderError(c('Error').t`The file you are trying to import is empty`);
142                 }
144                 const result = await beforeSubmit(importPayload);
146                 if (result.ok) {
147                     onSubmit?.(result.payload);
148                     importItems.dispatch({ data: result.payload, provider: values.provider });
149                 }
150             } catch (e) {
151                 if (e instanceof Error) {
152                     createNotification({ type: 'error', text: e.message });
153                 }
154             } finally {
155                 setBusy(false);
156             }
157         },
158     });
160     formRef.current = form;
162     const onAddFiles = (files: File[]) => {
163         try {
164             const file = createFileValidator(supportedFileTypes)(files);
165             void form.setValues((values) => ({ ...values, file }));
166         } catch (e: any) {
167             form.setErrors({ file: e.message });
168         }
169     };
171     const onDrop = (files: File[]) => {
172         setDropzoneHovered(false);
173         onAddFiles([...files]);
174     };
176     const onAttach: FileInputProps['onChange'] = (event) => onAddFiles((event.target.files as File[] | null) ?? []);
178     return {
179         busy,
180         dropzone: { hovered: dropzoneHovered, onAttach, onDrop, setSupportedFileTypes },
181         form,
182         result,
183     };