Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat remove api key #510

Merged
merged 3 commits into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 32 additions & 20 deletions src/components/ConfirmModal/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React, { useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import Button from "@mui/lab/LoadingButton";
import Modal from "~/components/ModalV2";
import Form from "~/components/FormV2";
import Container from "~/components/Container";
import Button from "~/components/ButtonV2";
import InfoBox from "~/components/InfoBox";

type ConfirmModalCallback = ({
setLoading,
Expand Down Expand Up @@ -47,14 +49,21 @@ const ConfirmModal: React.FC<Props> = ({
};

return (
<Modal open onClose={onCancel} className="sm:max-w-screen-sm">
<Container className="p-4">
<h2 className="font-bold text-2xl text-center mb-12">Confirm action</h2>
<div className="text-sm text-center">
<div className="mb-2">
<Modal open onClose={onCancel}>
<Box sx={{ p: 4 }}>
<Typography
variant="h2"
sx={{ textAlign: "center", fontSize: 24, mb: 4, fontWeight: "bold" }}
>
Confirm action
</Typography>
<Box sx={{ textAlign: "center" }}>
<Box sx={{ mb: 2 }}>
{children}
{!typeConfirmationText && " Are you sure you want to continue?"}
</div>
{!typeConfirmationText && (
<Typography>Are you sure you want to continue?</Typography>
)}
</Box>

{typeConfirmationText && (
<>
Expand All @@ -70,31 +79,34 @@ const ConfirmModal: React.FC<Props> = ({
/>
</>
)}
</div>
</Box>
{error && (
<InfoBox className="mt-8" type={InfoBox.ERROR}>
{error}
</InfoBox>
<Alert color="error" sx={{ mt: 4 }}>
<AlertTitle>Error</AlertTitle>
<Typography>{error}</Typography>
</Alert>
)}
<div className="mt-12 text-sm flex justify-center">
<Box sx={{ textAlign: "center", mt: 4 }}>
<Button
category="cancel"
color="info"
variant="text"
type="button"
className="mr-4 bg-blue-20"
sx={{ mr: 2 }}
onClick={onCancel}
>
Cancel
</Button>
<Button
category="action"
variant="contained"
color="secondary"
loading={loading}
disabled={!confirmButtonEnabled}
onClick={handleSuccess}
>
{confirmText || "Yes, continue"}
</Button>
</div>
</Container>
</Box>
</Box>
</Modal>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Scope } from "nock";
import { RenderResult, waitFor } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import mockApp from "~/testing/data/mock_app";
import mockEnvironments from "~/testing/data/mock_environments";
import {
mockFetchAPIKeys,
mockDeleteAPIKey,
} from "~/testing/nocks/nock_api_keys";
import TabAPIKey from "./TabAPIKey";

interface WrapperProps {
app?: App;
environments?: Environment[];
hash?: string;
setRefreshToken?: () => void;
}

describe("~/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx", () => {
let wrapper: RenderResult;
let fetchScope: Scope;
let currentApp: App;
let currentEnv: Environment;
let currentEnvs: Environment[];

const createWrapper = ({ app, environments }: WrapperProps) => {
currentApp = app || mockApp();
currentApp.id = "1644802351"; // Same as api key id

currentEnvs = environments || mockEnvironments({ app: currentApp });
currentEnv = currentEnvs[0];

fetchScope = mockFetchAPIKeys({
appId: currentApp.id,
envId: currentEnv.id!,
});

wrapper = render(<TabAPIKey app={currentApp} environment={currentEnv} />);
};

test("should fetch api keys", async () => {
createWrapper({});

await waitFor(() => {
expect(fetchScope.isDone()).toBe(true);
});

const subheader =
"This key will allow you to interact with our API and modify this environment.";

// Header
expect(wrapper.getByText("API Key")).toBeTruthy();
expect(wrapper.getByText(subheader)).toBeTruthy();

// API Keys
expect(wrapper.getByText("Default")).toBeTruthy();
expect(wrapper.getByText("CI")).toBeTruthy();
});

test("should delete api key", async () => {
createWrapper({});

await waitFor(() => {
expect(wrapper.getByText("CI")).toBeTruthy();
});

fireEvent.click(wrapper.getAllByLabelText("Remove API Key").at(1)!);

await waitFor(() => {
expect(wrapper.getByText("Confirm action")).toBeTruthy();
});

const scope = mockDeleteAPIKey({
appId: currentApp.id,
envId: currentEnv.id!,
keyId: "9868814106",
});

// Should refetch api keys
const fetchScope2 = mockFetchAPIKeys({
appId: currentApp.id,
envId: currentEnv.id!,
response: { keys: [] },
});

fireEvent.click(wrapper.getByText("Yes, continue"));

await waitFor(() => {
expect(scope.isDone()).toBe(true);
expect(fetchScope2.isDone()).toBe(true);
// Should close modal
expect(() => wrapper.getByText("Confirm action")).toThrow();
// Should no longer have API keys
expect(() => wrapper.getByText("Default")).toThrow();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import Button from "@mui/lab/LoadingButton";
import TextInput from "@mui/material/TextField";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import DeleteIcon from "@mui/icons-material/Delete";
import Modal from "~/components/ModalV2";
import ConfirmModal from "~/components/ConfirmModal";
import Spinner from "~/components/Spinner";
import InputDescription from "~/components/InputDescription";
import { useFetchAPIKeys, generateNewAPIKey } from "../actions";
import { useFetchAPIKeys, generateNewAPIKey, deleteAPIKey } from "../actions";

interface Props {
app: App;
environment: Environment;
setRefreshToken: (v: number) => void;
setRefreshToken?: (v: number) => void; // This is there just to accomodate the Tab signature. It's not really used.
}

interface APIKeyModalProps {
Expand Down Expand Up @@ -72,13 +74,16 @@ function APIKeyModal({ onSubmit, onClose, loading, error }: APIKeyModalProps) {

export default function TabAPIKey({ app, environment: env }: Props) {
const [isVisible, setIsVisible] = useState<string>("");
const [refreshToken, setRefreshToken] = useState<number>();
const [success, setSuccess] = useState(false);
const [modalError, setModalError] = useState("");
const [modalLoading, setModalLoading] = useState(false);
const [apiKeyToDelete, setApiKeyToDelete] = useState<APIKey>();
const [isModalOpen, setIsModalOpen] = useState(false);
const { loading, error, keys, setKeys } = useFetchAPIKeys({
appId: app.id,
envId: env.id!,
refreshToken,
});

const handleNewKey = (name: string) => {
Expand Down Expand Up @@ -146,33 +151,66 @@ export default function TabAPIKey({ app, environment: env }: Props) {
{keys.map(apiKey => (
<Box
key={apiKey.token}
sx={{ mb: 2, bgcolor: "rgba(0,0,0,0.1)", p: 2 }}
sx={{
mb: 2,
bgcolor: "rgba(0,0,0,0.1)",
p: 2,
}}
>
<Typography>{apiKey.name}</Typography>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
maxWidth: { md: "300px", lg: "none" },
}}
>
{isVisible === apiKey.id ? apiKey.token : "*".repeat(32)}
<Box>
<Typography>{apiKey.name}</Typography>
<Box
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
maxWidth: { md: "300px", lg: "none" },
}}
>
{isVisible === apiKey.id ? apiKey.token : "*".repeat(32)}
</Box>
</Box>
<Box>
<IconButton
title="Toggle visibility"
size="small"
sx={{
scale: "0.9",
opacity: 0.5,
":hover": { opacity: 1 },
}}
onClick={() => {
setIsVisible(isVisible === apiKey.id ? "" : apiKey.id);
}}
>
{isVisible === apiKey.id ? (
<VisibilityIcon />
) : (
<VisibilityOffIcon />
)}
</IconButton>
<IconButton
title="Remove API Key"
aria-label="Remove API Key"
size="small"
sx={{
scale: "0.9",
opacity: 0.5,
":hover": { opacity: 1 },
}}
onClick={() => {
setApiKeyToDelete(apiKey);
}}
>
<DeleteIcon />
</IconButton>
</Box>
<IconButton
title="Toggle visibility"
onClick={() => {
setIsVisible(isVisible === apiKey.id ? "" : apiKey.id);
}}
>
{isVisible ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</Box>
</Box>
))}
Expand Down Expand Up @@ -201,6 +239,38 @@ export default function TabAPIKey({ app, environment: env }: Props) {
onSubmit={handleNewKey}
/>
)}
{apiKeyToDelete && (
<ConfirmModal
onCancel={() => {
setApiKeyToDelete(undefined);
}}
onConfirm={({ setLoading, setError }) => {
setLoading(true);
setError(null);
setSuccess(false);

deleteAPIKey(apiKeyToDelete)
.then(() => {
setRefreshToken(Date.now());
})
.catch(() => {
setError("Something went wrong while deleting the API key.");
})
.finally(() => {
setLoading(false);
setApiKeyToDelete(undefined);
});

return "";
}}
>
<Typography>
This will delete the API key.
<br /> If you have any integration that uses this API key, it will
stop working.
</Typography>
</ConfirmModal>
)}
</Box>
);
}
15 changes: 13 additions & 2 deletions src/pages/apps/[id]/environments/[env-id]/config/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,14 @@ export const deleteEnvironment = ({
interface FetchAPIKeyProps {
appId: string;
envId: string;
refreshToken?: number;
}

export const useFetchAPIKeys = ({ appId, envId }: FetchAPIKeyProps) => {
export const useFetchAPIKeys = ({
appId,
envId,
refreshToken,
}: FetchAPIKeyProps) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [keys, setKeys] = useState<APIKey[]>([]);
Expand All @@ -346,11 +351,17 @@ export const useFetchAPIKeys = ({ appId, envId }: FetchAPIKeyProps) => {
.finally(() => {
setLoading(false);
});
}, [appId, envId]);
}, [appId, envId, refreshToken]);

return { loading, error, keys, setKeys };
};

export const deleteAPIKey = (apiKey: APIKey) => {
return api.delete<{ keys: APIKey[] }>(
`/app/${apiKey.appId}/env/${apiKey.envId}/api-key?keyId=${apiKey.id}`
);
};

interface GenerateNewAPIKeyProps {
appId: string;
envId?: string;
Expand Down
1 change: 1 addition & 0 deletions src/types/api_key.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ declare interface APIKey {
appId: string;
token: string;
envId?: string;
scope: "env";
}
1 change: 1 addition & 0 deletions testing/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export { default as mockAppResponse } from "./mock_app_response";
export { default as mockUserResponse } from "./mock_user_response";
export { default as mockRemoteConfigResponse } from "./mock_remote_config_response";
export { default as mockAdditionalSettingsResponse } from "./mock_settings_response";
export { default as mockAPIKeysResponse } from "./mock_api_keys_response";
Loading
Loading