mirror of
https://github.com/CryptKeeperZK/crypt-keeper-extension.git
synced 2026-01-10 06:28:09 -05:00
feat: upload integration (#651)
- [x] Integrate upload screen with services - [x] Fixes for wallet backup upload Co-authored-by: 0xmad <0xmad@users.noreply.github.com>
This commit is contained in:
@@ -207,10 +207,10 @@ export default class WalletService implements IBackupable {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawAuthenticBackup = this.cryptoService.getAuthenticCiphertext(backupEncryptedData, backupPassword);
|
||||
const authenticBackup = JSON.parse(rawAuthenticBackup) as { accounts: string; mnemonic: string };
|
||||
const authenticBackup = JSON.parse(backupEncryptedData) as { accounts: string; mnemonic: string };
|
||||
const accountsAuthenticBackup = this.cryptoService.getAuthenticCiphertext(authenticBackup.accounts, backupPassword);
|
||||
const newAccounts = JSON.parse(
|
||||
this.cryptoService.decrypt(authenticBackup.accounts, { secret: backupPassword }),
|
||||
this.cryptoService.decrypt(accountsAuthenticBackup, { secret: backupPassword }),
|
||||
) as IAccount[];
|
||||
|
||||
const encrypted = await this.accountStorage.get<string>();
|
||||
|
||||
@@ -2,8 +2,8 @@ import Box from "@mui/material/Box";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { forwardRef, Ref, type HTMLAttributes } from "react";
|
||||
|
||||
import type { HTMLAttributes } from "react";
|
||||
import type { Accept } from "react-dropzone";
|
||||
|
||||
import { onDropCallback, useUploadInput } from "./useUploadInput";
|
||||
@@ -16,14 +16,10 @@ export interface IUploadInputProps extends Omit<HTMLAttributes<HTMLInputElement>
|
||||
onDrop: onDropCallback;
|
||||
}
|
||||
|
||||
export const UploadInput = ({
|
||||
isLoading = false,
|
||||
multiple = true,
|
||||
errorMessage = "",
|
||||
accept,
|
||||
onDrop,
|
||||
...rest
|
||||
}: IUploadInputProps): JSX.Element => {
|
||||
export const UploadInputUI = (
|
||||
{ isLoading = false, multiple = true, errorMessage = "", accept, onDrop, ...rest }: IUploadInputProps,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
): JSX.Element => {
|
||||
const { isDragActive, acceptedFiles, getRootProps, getInputProps } = useUploadInput({
|
||||
isLoading,
|
||||
accept,
|
||||
@@ -34,7 +30,7 @@ export const UploadInput = ({
|
||||
const fileTitle = multiple ? "files" : "file";
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box
|
||||
{...getRootProps({ className: "dropzone" })}
|
||||
sx={{
|
||||
@@ -49,9 +45,10 @@ export const UploadInput = ({
|
||||
borderStyle: "dashed",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<input {...rest} {...getInputProps()} />
|
||||
<input ref={ref} {...rest} {...getInputProps()} />
|
||||
|
||||
{isDragActive ? <p>Drop the {fileTitle} here...</p> : <p>Drop some {fileTitle} here, or click to select</p>}
|
||||
</Box>
|
||||
@@ -68,3 +65,5 @@ export const UploadInput = ({
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const UploadInput = forwardRef<HTMLInputElement, IUploadInputProps>(UploadInputUI);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RPCAction } from "@cryptkeeperzk/providers";
|
||||
import { store } from "@src/ui/store/configureAppStore";
|
||||
import postMessage from "@src/util/postMessage";
|
||||
|
||||
import { downloadBackup } from "../backup";
|
||||
import { downloadBackup, uploadBackup } from "../backup";
|
||||
|
||||
jest.mock("@src/util/postMessage");
|
||||
|
||||
@@ -25,4 +25,16 @@ describe("ui/ducks/backup", () => {
|
||||
expect(postMessage).toBeCalledWith({ method: RPCAction.DOWNLOAD_BACKUP, payload: "password" });
|
||||
expect(result).toBe("content");
|
||||
});
|
||||
|
||||
test("should upload backup properly", async () => {
|
||||
await Promise.resolve(
|
||||
store.dispatch(uploadBackup({ password: "password", backupPassword: "password", content: "content" })),
|
||||
);
|
||||
|
||||
expect(postMessage).toBeCalledTimes(1);
|
||||
expect(postMessage).toBeCalledWith({
|
||||
method: RPCAction.UPLOAD_BACKUP,
|
||||
payload: { password: "password", backupPassword: "password", content: "content" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RPCAction } from "@cryptkeeperzk/providers";
|
||||
|
||||
import postMessage from "@src/util/postMessage";
|
||||
|
||||
import type { IUploadArgs } from "@src/types";
|
||||
import type { TypedThunk } from "@src/ui/store/configureAppStore";
|
||||
|
||||
export const downloadBackup =
|
||||
@@ -11,3 +12,15 @@ export const downloadBackup =
|
||||
method: RPCAction.DOWNLOAD_BACKUP,
|
||||
payload: password,
|
||||
});
|
||||
|
||||
export const uploadBackup =
|
||||
({ content, password, backupPassword }: IUploadArgs): TypedThunk<Promise<void>> =>
|
||||
async () =>
|
||||
postMessage({
|
||||
method: RPCAction.UPLOAD_BACKUP,
|
||||
payload: {
|
||||
content,
|
||||
password,
|
||||
backupPassword,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { Header } from "@src/ui/components/Header";
|
||||
import { Icon } from "@src/ui/components/Icon";
|
||||
import { PasswordInput } from "@src/ui/components/PasswordInput";
|
||||
import { UploadInput } from "@src/ui/components/UploadInput/UploadInput";
|
||||
@@ -13,10 +12,8 @@ const UploadBackup = (): JSX.Element => {
|
||||
const { isShowPassword, isLoading, errors, register, onDrop, onGoBack, onShowPassword, onSubmit } = useUploadBackup();
|
||||
|
||||
return (
|
||||
<Box data-testid="upload-backup-page">
|
||||
<Header />
|
||||
|
||||
<Box p={2}>
|
||||
<Box data-testid="upload-backup-page" sx={{ height: "100%" }}>
|
||||
<Box sx={{ height: "100%", p: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="h4">Upload backup</Typography>
|
||||
|
||||
@@ -25,12 +22,22 @@ const UploadBackup = (): JSX.Element => {
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ mt: 3, height: 430, display: "flex", flexDirection: "column", justifyContent: "space-between" }}
|
||||
sx={{
|
||||
mt: 2,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Box>
|
||||
<Typography fontWeight="bold" sx={{ my: 2 }} variant="body1">
|
||||
To upload your backup, please provide backup file and enter your backup password
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", flexGrow: 1, width: "100%" }}>
|
||||
<Typography fontWeight="bold" sx={{ mt: 1 }} variant="body1">
|
||||
To upload your backup, please provide backup file and enter your current and backup password.
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ my: 1, alignSelf: "flex-start" }} variant="body1">
|
||||
Note: backup will not override your password and mnemonic phrase
|
||||
</Typography>
|
||||
|
||||
<UploadInput
|
||||
@@ -42,34 +49,44 @@ const UploadBackup = (): JSX.Element => {
|
||||
{...register("backupFile", { required: "Backup file is required" })}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
isShowEye
|
||||
errorMessage={errors.password}
|
||||
id="password"
|
||||
isShowPassword={isShowPassword}
|
||||
label="Password"
|
||||
onShowPassword={onShowPassword}
|
||||
{...register("password", { required: "Password is required" })}
|
||||
/>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<PasswordInput
|
||||
isShowEye
|
||||
errorMessage={errors.password}
|
||||
id="password"
|
||||
isShowPassword={isShowPassword}
|
||||
label="Password"
|
||||
onShowPassword={onShowPassword}
|
||||
{...register("password", { required: "Password is required" })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<PasswordInput
|
||||
errorMessage={errors.backupPassword}
|
||||
id="backupPassword"
|
||||
isShowPassword={isShowPassword}
|
||||
label="Backup password"
|
||||
onShowPassword={onShowPassword}
|
||||
{...register("backupPassword", { required: "Backup password is required" })}
|
||||
/>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<PasswordInput
|
||||
errorMessage={errors.backupPassword}
|
||||
id="backupPassword"
|
||||
isShowPassword={isShowPassword}
|
||||
label="Backup password"
|
||||
onShowPassword={onShowPassword}
|
||||
{...register("backupPassword", { required: "Backup password is required" })}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", mb: 8 }}>
|
||||
<Button
|
||||
data-testid="upload-button"
|
||||
disabled={isLoading}
|
||||
sx={{ textTransform: "none", mt: 2, width: "100%" }}
|
||||
sx={{ textTransform: "none", width: "100%" }}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
<Typography color="error" sx={{ mt: 1, mx: 1, fontSize: "0.8125rem" }} variant="body2">
|
||||
{errors.root}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { act, renderHook } from "@testing-library/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { mockJsonFile } from "@src/config/mock/file";
|
||||
import { defaultWalletHookData } from "@src/config/mock/wallet";
|
||||
import { Paths } from "@src/constants";
|
||||
import { uploadBackup } from "@src/ui/ducks/backup";
|
||||
import { useAppDispatch } from "@src/ui/ducks/hooks";
|
||||
import { useCryptKeeperWallet } from "@src/ui/hooks/wallet";
|
||||
import { readFile } from "@src/util/file";
|
||||
|
||||
import { useUploadBackup } from "../useUploadBackup";
|
||||
|
||||
@@ -14,20 +19,40 @@ jest.mock("react-router-dom", (): unknown => ({
|
||||
useNavigate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@src/ui/hooks/wallet", (): unknown => ({
|
||||
useCryptKeeperWallet: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@src/ui/ducks/hooks", (): unknown => ({
|
||||
useAppDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@src/ui/ducks/backup", (): unknown => ({
|
||||
uploadBackup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@src/util/file", (): unknown => ({
|
||||
readFile: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("ui/pages/UploadBackup/useUploadBackup", () => {
|
||||
const mockNavigate = jest.fn();
|
||||
const mockDispatch = jest.fn(() => Promise.resolve());
|
||||
|
||||
beforeEach(() => {
|
||||
(readFile as jest.Mock).mockResolvedValue({ target: { result: "{}" } });
|
||||
|
||||
(useCryptKeeperWallet as jest.Mock).mockReturnValue(defaultWalletHookData);
|
||||
|
||||
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
|
||||
|
||||
(useAppDispatch as jest.Mock).mockReturnValue(mockDispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return initial data", () => {
|
||||
const { result } = renderHook(() => useUploadBackup());
|
||||
|
||||
@@ -37,6 +62,7 @@ describe("ui/pages/UploadBackup/useUploadBackup", () => {
|
||||
password: undefined,
|
||||
backupPassword: undefined,
|
||||
backupFile: undefined,
|
||||
root: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,4 +104,58 @@ describe("ui/pages/UploadBackup/useUploadBackup", () => {
|
||||
|
||||
expect(result.current.errors.backupFile).toBe("error");
|
||||
});
|
||||
|
||||
test("should submit properly", async () => {
|
||||
const { result } = renderHook(() => useUploadBackup());
|
||||
|
||||
await act(async () =>
|
||||
Promise.resolve(result.current.register("password").onChange({ target: { value: "password" } })),
|
||||
);
|
||||
|
||||
await act(() =>
|
||||
Promise.resolve(result.current.register("backupPassword").onChange({ target: { value: "backupPassword" } })),
|
||||
);
|
||||
|
||||
await act(() => Promise.resolve(result.current.onDrop([mockJsonFile], [], new Event("drop"))));
|
||||
|
||||
await act(() => Promise.resolve(result.current.onSubmit()));
|
||||
|
||||
expect(mockDispatch).toBeCalledTimes(1);
|
||||
expect(uploadBackup).toBeCalledTimes(1);
|
||||
expect(defaultWalletHookData.onConnect).toBeCalledTimes(1);
|
||||
expect(mockNavigate).toBeCalledTimes(1);
|
||||
expect(mockNavigate).toBeCalledWith(Paths.HOME);
|
||||
});
|
||||
|
||||
test("should handle submit error properly", async () => {
|
||||
const error = new Error("error");
|
||||
(mockDispatch as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useUploadBackup());
|
||||
|
||||
await act(() => Promise.resolve(result.current.onSubmit()));
|
||||
|
||||
expect(result.current.errors.root).toBe(error.message);
|
||||
});
|
||||
|
||||
test("should handle empty file read error properly", async () => {
|
||||
(readFile as jest.Mock).mockResolvedValue("");
|
||||
|
||||
const { result } = renderHook(() => useUploadBackup());
|
||||
|
||||
await act(() => Promise.resolve(result.current.onSubmit()));
|
||||
|
||||
expect(result.current.errors.root).toBe("Backup file is empty");
|
||||
});
|
||||
|
||||
test("should handle file read error properly", async () => {
|
||||
const error = new Error("error");
|
||||
(readFile as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useUploadBackup());
|
||||
|
||||
await act(() => Promise.resolve(result.current.onSubmit()));
|
||||
|
||||
expect(result.current.errors.root).toBe(error.message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,22 +3,26 @@ import { FileRejection } from "react-dropzone";
|
||||
import { UseFormRegister, useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Paths } from "@src/constants";
|
||||
import { uploadBackup } from "@src/ui/ducks/backup";
|
||||
import { useAppDispatch } from "@src/ui/ducks/hooks";
|
||||
import { useCryptKeeperWallet } from "@src/ui/hooks/wallet";
|
||||
import { readFile } from "@src/util/file";
|
||||
|
||||
import type { onDropCallback } from "@src/ui/components/UploadInput";
|
||||
|
||||
export interface IUseUploadBackupData {
|
||||
isLoading: boolean;
|
||||
isShowPassword: boolean;
|
||||
errors: { password?: string; backupPassword?: string; backupFile?: string };
|
||||
register: UseFormRegister<UploadBackupFields>;
|
||||
errors: { password?: string; backupPassword?: string; backupFile?: string; root?: string };
|
||||
register: UseFormRegister<IUploadBackupFields>;
|
||||
onDrop: onDropCallback;
|
||||
onSubmit: (event?: BaseSyntheticEvent) => Promise<void>;
|
||||
onShowPassword: () => void;
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
interface UploadBackupFields {
|
||||
interface IUploadBackupFields {
|
||||
password: string;
|
||||
backupPassword: string;
|
||||
backupFile: File;
|
||||
@@ -26,6 +30,7 @@ interface UploadBackupFields {
|
||||
|
||||
export const useUploadBackup = (): IUseUploadBackupData => {
|
||||
const [isShowPassword, setIsShowPassword] = useState(false);
|
||||
const { onConnect } = useCryptKeeperWallet();
|
||||
|
||||
const {
|
||||
formState: { isLoading, isSubmitting, errors },
|
||||
@@ -34,7 +39,7 @@ export const useUploadBackup = (): IUseUploadBackupData => {
|
||||
register,
|
||||
handleSubmit,
|
||||
clearErrors,
|
||||
} = useForm<UploadBackupFields>({
|
||||
} = useForm<IUploadBackupFields>({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
backupPassword: "",
|
||||
@@ -63,9 +68,31 @@ export const useUploadBackup = (): IUseUploadBackupData => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
// TODO: implement
|
||||
}, [dispatch, onGoBack, setError]);
|
||||
const onSubmit = useCallback(
|
||||
async (data: IUploadBackupFields) => {
|
||||
const content = await readFile(data.backupFile)
|
||||
.then((res) => {
|
||||
const text = res.target?.result;
|
||||
|
||||
if (!text) {
|
||||
setError("root", { message: "Backup file is empty" });
|
||||
}
|
||||
|
||||
return text?.toString();
|
||||
})
|
||||
.catch((error: Error) => setError("root", { message: error.message }));
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(uploadBackup({ password: data.password, backupPassword: data.backupPassword, content }))
|
||||
.then(() => onConnect())
|
||||
.then(() => navigate(Paths.HOME))
|
||||
.catch((error: Error) => setError("root", { message: error.message }));
|
||||
},
|
||||
[dispatch, navigate, setError, onConnect],
|
||||
);
|
||||
|
||||
const onShowPassword = useCallback(() => {
|
||||
setIsShowPassword((isShow) => !isShow);
|
||||
@@ -78,6 +105,7 @@ export const useUploadBackup = (): IUseUploadBackupData => {
|
||||
password: errors.password?.message,
|
||||
backupPassword: errors.backupPassword?.message,
|
||||
backupFile: errors.backupFile?.message,
|
||||
root: errors.root?.message,
|
||||
},
|
||||
register,
|
||||
onDrop,
|
||||
|
||||
21
packages/app/src/util/__tests__/file.test.ts
Normal file
21
packages/app/src/util/__tests__/file.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { mockJsonFile } from "@src/config/mock/file";
|
||||
|
||||
import { readFile } from "../file";
|
||||
|
||||
describe("util/file", () => {
|
||||
test("should read file properly", async () => {
|
||||
const result = await readFile(mockJsonFile);
|
||||
|
||||
expect(result.target?.result).toBe(JSON.stringify({ ping: true }));
|
||||
});
|
||||
|
||||
test("should return empty string is there is no read result", async () => {
|
||||
const result = await readFile(new File([], "name"));
|
||||
|
||||
expect(result.target?.result).toBe("");
|
||||
});
|
||||
});
|
||||
9
packages/app/src/util/file.ts
Normal file
9
packages/app/src/util/file.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const readFile = async (file: Blob): Promise<ProgressEvent<FileReader>> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.addEventListener("load", resolve);
|
||||
fileReader.addEventListener("error", reject);
|
||||
|
||||
fileReader.readAsText(file);
|
||||
});
|
||||
Reference in New Issue
Block a user