mirror of
https://github.com/morten-olsen/parcel.git
synced 2026-02-08 01:36:24 +01:00
sec
This commit is contained in:
@@ -3,16 +3,19 @@ import { hot } from 'react-hot-loader/root';
|
||||
import { Layout } from 'antd';
|
||||
import { GithubProvider } from './contexts/Github';
|
||||
import { EncryptionProvider } from './contexts/Encryption';
|
||||
import { DecryptionProvider } from './contexts/Decryption';
|
||||
import AppRouter from './Router';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<GithubProvider>
|
||||
<EncryptionProvider>
|
||||
<DecryptionProvider>
|
||||
<Layout style={{minHeight:"100vh"}}>
|
||||
<Layout.Content style={{ padding: '25px', maxWidth: '800px', width: '100%', margin: 'auto' }}>
|
||||
<AppRouter/>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</DecryptionProvider>
|
||||
</EncryptionProvider>
|
||||
</GithubProvider>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Encrypt from './screens/Encrypt';
|
||||
import Decrypt from './screens/Decrypt';
|
||||
import Welcome from './screens/Welcome';
|
||||
import Debug from './screens/Debug';
|
||||
|
||||
@@ -18,6 +19,9 @@ const AppRouter: React.FC = () => (
|
||||
<Route path="/welcome">
|
||||
<Welcome />
|
||||
</Route>
|
||||
<Route path="/decrypt">
|
||||
<Decrypt />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<Encrypt />
|
||||
</Route>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
List,
|
||||
Button,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -11,9 +10,8 @@ import {
|
||||
IssuesCloseOutlined,
|
||||
LockOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { FileType } from '../contexts/Encryption';
|
||||
import FileType from '../types/File';
|
||||
import { downloadLink } from '../helpers/files';
|
||||
|
||||
interface Props {
|
||||
@@ -22,22 +20,11 @@ interface Props {
|
||||
}
|
||||
|
||||
const icons: {[name: string]: any} = {
|
||||
encrypting: <SyncOutlined spin />,
|
||||
processing: <SyncOutlined spin />,
|
||||
failed: <IssuesCloseOutlined />,
|
||||
encrypted: <LockOutlined />,
|
||||
success: <LockOutlined />,
|
||||
};
|
||||
|
||||
const share = async (file: FileType, fileData: File[]) => {
|
||||
try {
|
||||
navigator.share({
|
||||
title: file.name,
|
||||
files: fileData,
|
||||
} as any);
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
}
|
||||
}
|
||||
|
||||
const IconText = ({ icon, text, ...props }) => (
|
||||
<Button
|
||||
{...props}
|
||||
@@ -50,13 +37,9 @@ const FileView: React.FC<Props> = ({
|
||||
remove,
|
||||
}) => {
|
||||
const icon = icons[file.status];
|
||||
const fileData = useMemo(() => [new File([file.link || ''], file.name, {
|
||||
type: 'text/plain',
|
||||
})], [file]);
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (file.link) {
|
||||
if (file.blob) {
|
||||
actions.push(
|
||||
<Popconfirm
|
||||
title="Are you sure delete this file?"
|
||||
@@ -73,23 +56,13 @@ const FileView: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!!navigator.share && (navigator as any).canSare && (navigator as any).canShare({ files: fileData })) {
|
||||
actions.push(
|
||||
<IconText
|
||||
icon={ShareAltOutlined}
|
||||
text="Share"
|
||||
onClick={() => share(file, fileData)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.link) {
|
||||
if (file.blob) {
|
||||
actions.push(
|
||||
<IconText
|
||||
icon={DownloadOutlined}
|
||||
type="primary"
|
||||
text="Download"
|
||||
onClick={() => downloadLink(file.name, file.link!)}
|
||||
onClick={() => downloadLink(file.name, file.blob!)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -101,7 +74,6 @@ const FileView: React.FC<Props> = ({
|
||||
<List.Item.Meta
|
||||
avatar={icon}
|
||||
title={file.name}
|
||||
description={`Encrypted for ${file.reciever}`}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { Space, List, Empty, Button } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import EncryptionContext from '../contexts/Encryption';
|
||||
import useDownloadAll from '../hooks/useDownloadAll';
|
||||
import File from './File';
|
||||
import FileType from '../types/File';
|
||||
|
||||
const Encrypt: React.FC = () => {
|
||||
const { files, deleteFile } = useContext(EncryptionContext);
|
||||
interface Props {
|
||||
files: {[id: string]: FileType};
|
||||
deleteFile: (id: string) => void;
|
||||
}
|
||||
|
||||
const Encrypt: React.FC<Props> = ({
|
||||
files,
|
||||
deleteFile,
|
||||
}) => {
|
||||
const { status, downloadAll } = useDownloadAll();
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Theme } from '../theme';
|
||||
|
||||
interface Props {
|
||||
left?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
title?: string;
|
||||
body?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Cell = styled.div<{ theme: Theme }>`
|
||||
padding: ${({ theme }) => theme.margin.medium / 2}px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Cell)`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #dfe6e9;
|
||||
margin-top: ${({ theme }) => theme.margin.medium}px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
padding: 0px;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
`
|
||||
|
||||
const Main = styled(Cell)`
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
const Row: React.FC<Props> = ({
|
||||
left,
|
||||
right,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}) => (
|
||||
<Wrapper style={{ display: 'flex' }}>
|
||||
{left}
|
||||
<Main>
|
||||
{title && <Title>{title}</Title>}
|
||||
{body}
|
||||
{children}
|
||||
</Main>
|
||||
{right}
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export {
|
||||
Cell,
|
||||
};
|
||||
|
||||
export default Row;
|
||||
36
src/components/decrypt/AddFile.tsx
Normal file
36
src/components/decrypt/AddFile.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useContext, useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Layout } from 'antd';
|
||||
import { FileAddTwoTone } from '@ant-design/icons';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import DecryptionContext from '../../contexts/Decryption';
|
||||
|
||||
const Icon = styled(FileAddTwoTone)`
|
||||
font-size: 100px;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const DropWrapper = styled(Layout)`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 50px;
|
||||
`;
|
||||
|
||||
const AddFile: React.FC = () => {
|
||||
const { addFile } = useContext(DecryptionContext);
|
||||
const onDrop = useCallback(acceptedFiles => {
|
||||
acceptedFiles.forEach(addFile);
|
||||
}, [])
|
||||
const {getRootProps, getInputProps} = useDropzone({ onDrop });
|
||||
return (
|
||||
<DropWrapper {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<Icon />
|
||||
<p>Drag 'n' drop some files here, or click to select files</p>
|
||||
</DropWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFile;
|
||||
@@ -3,7 +3,7 @@ import styled from 'styled-components';
|
||||
import { Layout } from 'antd';
|
||||
import { FileAddTwoTone } from '@ant-design/icons';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import EncryptionContext from '../contexts/Encryption';
|
||||
import EncryptionContext from '../../contexts/Encryption';
|
||||
|
||||
const Icon = styled(FileAddTwoTone)`
|
||||
font-size: 100px;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useContext } from 'react';
|
||||
import { Input, Form, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import EncryptionContext from '../contexts/Encryption';
|
||||
import EncryptionContext from '../../contexts/Encryption';
|
||||
|
||||
const AddText : React.FC = () => {
|
||||
const { addText } = useContext(EncryptionContext);
|
||||
106
src/contexts/Decryption.tsx
Normal file
106
src/contexts/Decryption.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState, useCallback, useContext, createContext, useEffect } from 'react';
|
||||
import * as openpgp from 'openpgp';
|
||||
import GithubContext from './Github';
|
||||
import FileType from '../types/File';
|
||||
import { createFile } from '../helpers/files';
|
||||
|
||||
interface DecryptionContextType {
|
||||
publicKey: string | undefined;
|
||||
files: {[id: string]: FileType};
|
||||
addFile: (file: File) => Promise<void>;
|
||||
deleteFile: (id: string) => void;
|
||||
}
|
||||
|
||||
const DecryptionContext = createContext<DecryptionContextType>({
|
||||
publicKey: undefined,
|
||||
files: {},
|
||||
addFile: async () => { throw new Error('Not using provider'); },
|
||||
deleteFile: async () => { throw new Error('Not using provider'); },
|
||||
});
|
||||
|
||||
const decrypt = async (privateKey: string, keys: string[], content: string) => {
|
||||
const armoredKeys = await Promise.all(keys.map(openpgp.key.readArmored));
|
||||
const message = openpgp.message.fromText(content);
|
||||
const encrypted = await openpgp.decrypt({
|
||||
message,
|
||||
privateKeys: [...(await openpgp.key.readArmored(privateKey)).keys],
|
||||
publicKeys: armoredKeys.reduce<any>((output, key: any) => [...output, ...key.keys], []),
|
||||
});
|
||||
const { data } = encrypted;
|
||||
const blob = new Blob([data], {
|
||||
type: 'text/text',
|
||||
});
|
||||
return blob;
|
||||
};
|
||||
|
||||
const DecryptionProvider: React.FC = ({
|
||||
children,
|
||||
}) => {
|
||||
const { username, keys } = useContext(GithubContext);
|
||||
const [privateKey, setPrivateKey] = useState<string | undefined>(undefined);
|
||||
const [publicKey, setPublicKey] = useState<string | undefined>(undefined);
|
||||
const [files, setFiles] = useState<DecryptionContextType['files']>({});
|
||||
|
||||
const deleteFile = useCallback((id: string) => {
|
||||
delete files[id];
|
||||
setFiles({
|
||||
...files,
|
||||
});
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const currentRawKey = localStorage.getItem('key');
|
||||
if (currentRawKey) {
|
||||
setPrivateKey(currentRawKey);
|
||||
const key = await openpgp.key.readArmored(currentRawKey);
|
||||
setPublicKey(key.keys[0].toPublic().armor());
|
||||
} else {
|
||||
const key = await openpgp.generateKey({
|
||||
userIds: [{ name: 'unknown unknown', email: 'unknown@unknown.foo'}],
|
||||
curve: 'ed25519',
|
||||
});
|
||||
|
||||
setPrivateKey(key.privateKeyArmored);
|
||||
setPublicKey(key.publicKeyArmored);
|
||||
localStorage.setItem('key', key.privateKeyArmored);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, []);
|
||||
|
||||
const addFile = useCallback(async (file: File) => {
|
||||
if (!keys || !privateKey) return;
|
||||
const addedFile = createFile(setFiles, file.name);
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onabort = addedFile.setFailed,
|
||||
reader.onerror = addedFile.setFailed,
|
||||
reader.onload = () => {
|
||||
addedFile.setContent(
|
||||
decrypt(privateKey, keys, reader.result as string),
|
||||
);
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [keys, username]);
|
||||
|
||||
return (
|
||||
<DecryptionContext.Provider
|
||||
value={{
|
||||
publicKey,
|
||||
files,
|
||||
addFile,
|
||||
deleteFile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DecryptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
DecryptionProvider,
|
||||
};
|
||||
|
||||
export default DecryptionContext;;
|
||||
@@ -1,16 +1,8 @@
|
||||
import React, { useState, useCallback, useContext, createContext } from 'react';
|
||||
import * as openpgp from 'openpgp';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { message } from 'antd';
|
||||
import GithubContext from './Github';
|
||||
|
||||
export interface FileType {
|
||||
name: string;
|
||||
reciever: string;
|
||||
status: 'encrypting' | 'failed' | 'encrypted';
|
||||
error?: any;
|
||||
link?: Blob;
|
||||
}
|
||||
import { createFile } from '../helpers/files';
|
||||
import FileType from '../types/File';
|
||||
|
||||
interface EncryptionContextType {
|
||||
files: {[id: string]: FileType};
|
||||
@@ -55,74 +47,27 @@ const EncryptionProvider: React.FC = ({
|
||||
});
|
||||
}, [files]);
|
||||
|
||||
const add = (name: string) => {
|
||||
const id = nanoid();
|
||||
const file: FileType = {
|
||||
name: `${name}.asc`,
|
||||
reciever: username,
|
||||
status: 'encrypting',
|
||||
};
|
||||
setFiles(files => ({
|
||||
...files,
|
||||
[id]: file,
|
||||
}));
|
||||
|
||||
const setError = (err: any) => {
|
||||
console.error(err);
|
||||
setFiles(files => ({
|
||||
...files,
|
||||
[id]: {
|
||||
...files[id],
|
||||
status: 'failed',
|
||||
error: err,
|
||||
},
|
||||
}));
|
||||
message.error(`Failed to encrypt ${name}`);
|
||||
};
|
||||
|
||||
const setContent = (text: string, keys: string[]) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const encrypted = await encrypt(keys, text);
|
||||
setFiles(files => ({
|
||||
...files,
|
||||
[id]: {
|
||||
...files[id],
|
||||
link: encrypted,
|
||||
status: 'encrypted'
|
||||
},
|
||||
}));
|
||||
message.success(`Done encrypting ${name}`);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
run();
|
||||
};
|
||||
|
||||
return {
|
||||
setContent,
|
||||
setError,
|
||||
};
|
||||
}
|
||||
|
||||
const addFile = useCallback(async (file: File) => {
|
||||
if (!keys) return;
|
||||
const addedFile = add(file.name);
|
||||
const addedFile = createFile(setFiles, `${file.name}.acs`);
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onabort = addedFile.setError,
|
||||
reader.onerror = addedFile.setError,
|
||||
reader.onabort = addedFile.setFailed,
|
||||
reader.onerror = addedFile.setFailed,
|
||||
reader.onload = () => {
|
||||
addedFile.setContent(reader.result as string, keys);
|
||||
addedFile.setContent(
|
||||
encrypt(keys, reader.result as string),
|
||||
);
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [keys, username]);
|
||||
|
||||
const addText = useCallback(async (text: string, name: string) => {
|
||||
if (!keys) return;
|
||||
const file = add(`${name}.txt`);
|
||||
file.setContent(text, keys);
|
||||
const file = createFile(setFiles, `${name}.txt.asc`);
|
||||
file.setContent(
|
||||
encrypt(keys, text),
|
||||
);
|
||||
}, [keys, username]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import File from '../types/File';
|
||||
|
||||
export const downloadLink = (name: string, blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const downloadLink = document.createElement('a');
|
||||
@@ -7,3 +10,49 @@ export const downloadLink = (name: string, blob: Blob) => {
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
//type SetFilesType = (fn: (files: {[id: string]: File}) => {[id: string]: File}) => any;
|
||||
type SetFilesType = any;
|
||||
|
||||
export const createFile = (setFiles: SetFilesType, name: string) => {
|
||||
const id = nanoid();
|
||||
const file: File = {
|
||||
name,
|
||||
status: 'processing',
|
||||
};
|
||||
setFiles((files) => ({
|
||||
...files,
|
||||
[id]: file,
|
||||
}));
|
||||
|
||||
const setContent = (input: Blob | Promise<Blob>) => {
|
||||
Promise.resolve(input)
|
||||
.then((blob) => {
|
||||
setFiles((files) => ({
|
||||
...files,
|
||||
[id]: {
|
||||
...files[id],
|
||||
blob,
|
||||
status: 'success',
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch(setFailed);
|
||||
}
|
||||
|
||||
const setFailed = (err: any) => {
|
||||
setFiles((files) => ({
|
||||
...files,
|
||||
[id]: {
|
||||
...files[id],
|
||||
status: 'failed',
|
||||
error: err,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
setContent,
|
||||
setFailed,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,14 +8,14 @@ type Statuses = 'packing' | 'ready';
|
||||
const useDownloadAll = () => {
|
||||
const [status, setStatus] = useState<Statuses>('ready');
|
||||
const { files } = useContext(EncryptionContext);
|
||||
const allFilesReady = Object.values(files).filter(f => f.link).length > 1;
|
||||
const allFilesReady = Object.values(files).filter(f => f.status === 'success').length > 1;
|
||||
|
||||
const downloadAll = useCallback(() => {
|
||||
setStatus('packing');
|
||||
const run = async () => {
|
||||
const zip = new Zip();
|
||||
Object.values(files).map((file) => {
|
||||
zip.file(file.name, file.link!);
|
||||
zip.file(file.name, file.blob!);
|
||||
});
|
||||
const link = await zip.generateAsync({ type: 'blob' });
|
||||
setStatus('ready');
|
||||
|
||||
44
src/screens/Decrypt.tsx
Normal file
44
src/screens/Decrypt.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useContext, useEffect, useCallback } from 'react';
|
||||
import { Divider, Button } from 'antd';
|
||||
import { useHistory } from 'react-router';
|
||||
import FileList from '../components/FileList';
|
||||
import Add from '../components/decrypt/AddFile';
|
||||
import DecryptionContext from '../contexts/Decryption';
|
||||
import { downloadLink } from '../helpers/files';
|
||||
|
||||
const Decrypt: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { publicKey, files, deleteFile } = useContext(DecryptionContext);
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('welcome') !== 'seen') {
|
||||
history.replace('/welcome');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const downloadPublicKey = useCallback(() => {
|
||||
const publicKeyBlob = new Blob([publicKey!]);
|
||||
downloadLink('public-key.asc', publicKeyBlob);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={downloadPublicKey}
|
||||
>
|
||||
Download you sharing key
|
||||
</Button>
|
||||
<Add />
|
||||
{Object.keys(files).length > 0 && (
|
||||
<>
|
||||
<Divider>Files</Divider>
|
||||
<FileList
|
||||
files={files}
|
||||
deleteFile={deleteFile}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Decrypt;
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import { useHistory } from 'react-router';
|
||||
import Add from '../components/Add';
|
||||
import Add from '../components/encrypt/Add';
|
||||
import FileList from '../components/FileList';
|
||||
import EncryptionContext from '../contexts/Encryption';
|
||||
|
||||
const Encrypt: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { files } = useContext(EncryptionContext);
|
||||
const { files, deleteFile } = useContext(EncryptionContext);
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('welcome') !== 'seen') {
|
||||
history.replace('/welcome');
|
||||
@@ -20,7 +20,14 @@ const Encrypt: React.FC = () => {
|
||||
{Object.keys(files).length > 0 && (
|
||||
<>
|
||||
<Divider>Files</Divider>
|
||||
<FileList />
|
||||
<FileList
|
||||
files={files}
|
||||
deleteFile={deleteFile}
|
||||
/>
|
||||
<Divider />
|
||||
<i style={{ textAlign: 'center', paddingTop: '10px', display: 'block', fontSize: 12 }}>
|
||||
Note: files are not send to me, you still have to download the encrypted files and send it to me.
|
||||
</i>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
24
src/types/File.ts
Normal file
24
src/types/File.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
interface FileBase {
|
||||
name: string;
|
||||
status: string;
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
interface FileProcessing extends FileBase {
|
||||
name: string;
|
||||
status: 'processing';
|
||||
}
|
||||
|
||||
interface FileFailed extends FileBase {
|
||||
status: 'failed',
|
||||
error: any;
|
||||
}
|
||||
|
||||
interface FileSuccess extends FileBase {
|
||||
status: 'success';
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
type FileType = FileProcessing | FileFailed | FileSuccess;
|
||||
|
||||
export default FileType;
|
||||
Reference in New Issue
Block a user